tests/ab-tests.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type { ABTestBranch, ABTestConfig } from '../supabase/functions/_backend/utils/ab_tests.ts'
import { readFile } from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import rawConfig from '../supabase/functions/_backend/utils/ab_tests.json'
import { PROSPECTIVE_COHORT_FIXTURE } from './helpers/ab-test-prospective-cohort.ts'
const {
backgroundTaskMock,
closeClientMock,
drizzleExecuteMock,
drizzleTransactionMock,
getDrizzleClientMock,
getPgClientMock,
pgConnectMock,
pgQueryMock,
pgReleaseMock,
syncBentoSubscriberTagsMock,
} = vi.hoisted(() => {
const pgQueryMock = vi.fn<(
query: string,
params?: unknown[],
) => Promise<{ rows: Record<string, unknown>[] }>>(async () => ({ rows: [] }))
const pgReleaseMock = vi.fn<(destroy?: Error | boolean) => void>(() => undefined)
const pgConnectMock = vi.fn(async () => ({ query: pgQueryMock, release: pgReleaseMock }))
const drizzleExecuteMock = vi.fn<(query: unknown) => Promise<{ rows: Record<string, unknown>[] }>>(async () => ({ rows: [] }))
const drizzleTransactionMock = vi.fn(async (callback: (tx: { execute: typeof drizzleExecuteMock }) => Promise<unknown>) => {
return await callback({ execute: drizzleExecuteMock })
})
return {
backgroundTaskMock: vi.fn(async (_c: unknown, task: Promise<unknown>) => await task),
closeClientMock: vi.fn(async () => undefined),
drizzleExecuteMock,
drizzleTransactionMock,
getDrizzleClientMock: vi.fn(() => ({ transaction: drizzleTransactionMock })),
getPgClientMock: vi.fn(() => ({ connect: pgConnectMock })),
pgConnectMock,
pgQueryMock,
pgReleaseMock,
syncBentoSubscriberTagsMock: vi.fn<(
c: unknown,
update: { deleteSegments: string[], email: string, segments: string[] },
signal?: AbortSignal,
) => Promise<boolean | undefined>>(async () => true),
}
})
vi.mock('../supabase/functions/_backend/utils/bento.ts', () => ({
syncBentoSubscriberTags: syncBentoSubscriberTagsMock,
}))
vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => ({
...(await import('./helpers/pg-checkout-release-mocks.ts')),
closeClient: closeClientMock,
getDrizzleClient: getDrizzleClientMock,
getPgClient: getPgClientMock,
}))
vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({
backgroundTask: backgroundTaskMock,
}))
type ABTestsConfig = Record<string, ABTestConfig>
type ABTestsModule = typeof import('../supabase/functions/_backend/utils/ab_tests.ts')
const modulePath = '../supabase/functions/_backend/utils/ab_tests.ts'
const FIXED_DATE = new Date('2026-08-23T12:34:56.000Z')
const USER_ID = '11111111-1111-4111-8111-111111111111'
const INTENT_TEST_NAME = 'intent_targeted'
const BUILDER_INTENT_TEST_NAME = 'builder_intent_targeted'
const BUILDER_TODO_TEST_NAME = 'builder_todo_list_v4'
const NEW_CHANNEL_TEST_NAME = 'new_channel'
const WEBNATIVE_TEST_NAMES = ['webnativeapp_publish_intent', 'webnativeapp_development_environment'] as const
async function loadABTestsModule() {
return await import(/* @vite-ignore */ modulePath) as ABTestsModule
}
function testConfig(
treatmentPercentage = 50,
audience: ABTestConfig['audience'] = 'self_signup',
treatmentBranch: ABTestBranch = 'A',
controlBranch: ABTestBranch = 'B',
intents?: ABTestConfig['intents'],
): ABTestsConfig {
return {
new_emails: {
audience,
label: 'Email template',
...(intents ? { intents } : {}),
control_branch: controlBranch,
treatment_branch: treatmentBranch,
treatment_percentage: treatmentPercentage,
branches: {
[treatmentBranch]: { bento_tag: 'ab:new_emails', label: 'New emails' },
[controlBranch]: { bento_tag: 'ab:no_new_emails', label: 'Old emails' },
},
},
}
}
function installIntentTest(
module: ABTestsModule,
intents: ABTestConfig['intents'] = ['ota'],
testName = INTENT_TEST_NAME,
) {
const config = module.validateABTestsConfig({
[testName]: {
...testConfig().new_emails,
intents,
branches: {
A: { bento_tag: `ab:${testName}`, label: 'Treatment' },
B: { bento_tag: `ab:no_${testName}`, label: 'Control' },
},
},
})
module.AB_TESTS_CONFIG[testName] = config[testName]
}
function enableNewChannelTest(module: ABTestsModule) {
module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME] = {
...module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME]!,
treatment_percentage: 50,
}
}
function enableWebNativeTests(module: ABTestsModule) {
for (const testName of WEBNATIVE_TEST_NAMES)
module.AB_TESTS_CONFIG[testName].treatment_percentage = 10
}
function intentAssignment(branch: 'A' | 'B' = 'A') {
return { assigned_at: FIXED_DATE.toISOString(), branch }
}
function collectSqlParameterValues(chunk: unknown): unknown[] {
if (Array.isArray(chunk))
return chunk.flatMap(collectSqlParameterValues)
if (!chunk || typeof chunk !== 'object')
return [chunk]
const sqlChunk = chunk as { queryChunks?: unknown, value?: unknown }
const values = Array.isArray(sqlChunk.queryChunks)
? collectSqlParameterValues(sqlChunk.queryChunks)
: []
if (sqlChunk.value !== undefined && !Array.isArray(sqlChunk.value))
values.push(sqlChunk.value)
return values
}
function persistedAssignments(branches: { channel?: 'A' | 'B' | null, development?: 'C' | 'D', emails?: 'A' | 'B', publish?: 'A' | 'B' } = {}) {
const assignments = {
new_emails: { assigned_at: FIXED_DATE.toISOString(), branch: branches.emails ?? 'A' },
webnativeapp_publish_intent: { assigned_at: FIXED_DATE.toISOString(), branch: branches.publish ?? 'A' },
webnativeapp_development_environment: { assigned_at: FIXED_DATE.toISOString(), branch: branches.development ?? 'C' },
}
if (branches.channel === null)
return assignments
return {
...assignments,
new_channel: { assigned_at: FIXED_DATE.toISOString(), branch: branches.channel ?? 'A' },
}
}
function queueBentoSnapshot(user: Record<string, unknown>, writesState = false) {
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [user] })
if (writesState)
drizzleExecuteMock.mockResolvedValueOnce({ rows: [] })
}
describe('new-user A/B test assignment', () => {
beforeEach(async () => {
const module = await loadABTestsModule()
module.AB_TESTS_CONFIG.new_emails = testConfig().new_emails
vi.resetAllMocks()
pgConnectMock.mockImplementation(async () => ({ query: pgQueryMock, release: pgReleaseMock }))
getPgClientMock.mockImplementation(() => ({ connect: pgConnectMock }))
getDrizzleClientMock.mockImplementation(() => ({ transaction: drizzleTransactionMock }))
drizzleTransactionMock.mockImplementation(async callback => await callback({ execute: drizzleExecuteMock }))
drizzleExecuteMock.mockResolvedValue({ rows: [] })
closeClientMock.mockResolvedValue(undefined)
backgroundTaskMock.mockImplementation(async (_c, task) => await task)
syncBentoSubscriberTagsMock.mockResolvedValue(true)
})
afterEach(async () => {
const module = await loadABTestsModule()
const productionConfig = module.validateABTestsConfig(rawConfig)
module.AB_TESTS_CONFIG.new_emails = productionConfig.new_emails
delete module.AB_TESTS_CONFIG[INTENT_TEST_NAME]
delete module.AB_TESTS_CONFIG[BUILDER_INTENT_TEST_NAME]
module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME] = {
...module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME]!,
treatment_percentage: 0,
}
for (const testName of WEBNATIVE_TEST_NAMES)
module.AB_TESTS_CONFIG[testName].treatment_percentage = 0
vi.useRealTimers()
vi.restoreAllMocks()
})
it('declares the JSON import type required by the Supabase Deno runtime', async () => {
const source = await readFile(new URL('../supabase/functions/_backend/utils/ab_tests.ts', import.meta.url), 'utf8')
expect(source).toContain('from \'./ab_tests.json\' with { type: \'json\' }')
})
it.each([
[0, 'A'],
[0.4999, 'A'],
[0.5, 'B'],
[0.9999, 'B'],
] as const)('uses the configured 50%% treatment boundary for random value %s', async (randomValue, branch) => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
const config = validateABTestsConfig(testConfig())
expect(createABTestAssignments(
{ created_via_invite: false },
config,
() => randomValue,
() => FIXED_DATE,
)).toEqual({
new_emails: {
assigned_at: FIXED_DATE.toISOString(),
branch,
},
})
})
it('leaves both branches unassigned at 0% treatment', async () => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
const config = validateABTestsConfig(testConfig(0))
const random = vi.fn(() => 0)
expect(createABTestAssignments({ created_via_invite: false }, config, random, () => FIXED_DATE)).toEqual({})
expect(random).not.toHaveBeenCalled()
})
it('leaves a disabled all-user test unassigned at creation', async () => {
const module = await loadABTestsModule()
const configured = module.AB_TESTS_CONFIG[BUILDER_TODO_TEST_NAME]
expect(configured).toMatchObject({ audience: 'all', intents: ['builder'], treatment_percentage: 100 })
const disabled = { ...configured, intents: undefined, treatment_percentage: 0 }
expect(module.createABTestAssignments(
{ created_via_invite: false, intent: 'builder' },
{ [BUILDER_TODO_TEST_NAME]: disabled },
() => 0,
() => FIXED_DATE,
)).toEqual({})
module.AB_TESTS_CONFIG[BUILDER_TODO_TEST_NAME] = disabled
try {
const result = module.createABTestAssignments({ created_via_invite: true }, module.AB_TESTS_CONFIG)
expect(result).toEqual({})
}
finally {
module.AB_TESTS_CONFIG[BUILDER_TODO_TEST_NAME] = configured
}
})
it('uses 100% treatment allocation to always select branch A', async () => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
const config = validateABTestsConfig(testConfig(100))
expect(createABTestAssignments(
{ created_via_invite: false },
config,
() => 0.75,
() => FIXED_DATE,
).new_emails?.branch).toBe('A')
})
it.each([
[0, 'C'],
[0.2499, 'C'],
[0.25, 'D'],
[0.9999, 'D'],
] as const)('supports a 25/75 C/D experiment for random value %s', async (randomValue, branch) => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
const config = validateABTestsConfig(testConfig(25, 'self_signup', 'C', 'D'))
expect(createABTestAssignments(
{ created_via_invite: false },
config,
() => randomValue,
() => FIXED_DATE,
).new_emails?.branch).toBe(branch)
})
it('excludes invited users from self-signup experiments', async () => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
expect(createABTestAssignments(
{ created_via_invite: true },
validateABTestsConfig(testConfig()),
() => 0,
() => FIXED_DATE,
)).toEqual({})
})
it('includes invited users in all-user experiments', async () => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
expect(createABTestAssignments(
{ created_via_invite: true },
validateABTestsConfig(testConfig(50, 'all')),
() => 0,
() => FIXED_DATE,
).new_emails?.branch).toBe('A')
})
it('accepts unique supported intent targeting', async () => {
const { validateABTestsConfig } = await loadABTestsModule()
expect(validateABTestsConfig(testConfig(50, 'self_signup', 'A', 'B', ['ota', 'both'])).new_emails?.intents)
.toEqual(['ota', 'both'])
})
it('preserves display labels for the admin dashboard', async () => {
const { validateABTestsConfig } = await loadABTestsModule()
const config = validateABTestsConfig(testConfig())
expect(config.new_emails?.label).toBe('Email template')
expect(config.new_emails?.branches.A.label).toBe('New emails')
expect(config.new_emails?.branches.B.label).toBe('Old emails')
})
it.each([
['an empty list', []],
['duplicate values', ['ota', 'ota']],
['an unsupported value', ['unsupported']],
])('rejects intent targeting with %s', async (_label, intents) => {
const { validateABTestsConfig } = await loadABTestsModule()
expect(() => validateABTestsConfig(testConfig(50, 'self_signup', 'A', 'B', intents as never)))
.toThrow('Invalid A/B test configuration')
})
it('requires an exact persisted intent before assigning an intent-gated test', async () => {
const { createABTestAssignments, validateABTestsConfig } = await loadABTestsModule()
const config = validateABTestsConfig(testConfig(50, 'self_signup', 'A', 'B', ['ota']))
expect(createABTestAssignments(
{ created_via_invite: false },
config,
() => 0,
() => FIXED_DATE,
)).toEqual({})
expect(createABTestAssignments(
{ created_via_invite: false, intent: 'both' },
config,
() => 0,
() => FIXED_DATE,
)).toEqual({})
expect(createABTestAssignments(
{ created_via_invite: false, intent: 'ota' },
config,
() => 0,
() => FIXED_DATE,
).new_emails).toEqual({
assigned_at: FIXED_DATE.toISOString(),
branch: 'A',
})
})
it('assigns the configured channel experiment only after an exact eligible persisted intent', async () => {
const { AB_TESTS_CONFIG, createABTestAssignments } = await loadABTestsModule()
const config = {
[NEW_CHANNEL_TEST_NAME]: {
...AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME]!,
treatment_percentage: 50,
},
}
expect(createABTestAssignments(
{ created_via_invite: false },
config,
() => 0,
() => FIXED_DATE,
)).toEqual({})
expect(createABTestAssignments(
{ created_via_invite: false, intent: 'builder' },
config,
() => 0,
() => FIXED_DATE,
)).toEqual({})
expect(createABTestAssignments(
{ created_via_invite: false, intent: 'ota' },
config,
() => 0,
() => FIXED_DATE,
)).toEqual({
[NEW_CHANNEL_TEST_NAME]: intentAssignment('A'),
})
expect(createABTestAssignments(
{ created_via_invite: false, intent: 'both' },
config,
() => 0.5,
() => FIXED_DATE,
)).toEqual({
[NEW_CHANNEL_TEST_NAME]: intentAssignment('B'),
})
})
it.each([
['a non-object config', null],
['an unsupported audience', testConfig(50, 'invalid' as 'all')],
['a blank test label', {
new_emails: {
...testConfig().new_emails,
label: ' ',
},
}],
['a blank branch label', {
new_emails: {
...testConfig().new_emails,
branches: {
...testConfig().new_emails.branches,
A: { bento_tag: 'ab:new_emails', label: ' ' },
},
},
}],
['a fractional percentage', testConfig(50.5)],
['a percentage below zero', testConfig(-1)],
['a percentage above 100', testConfig(101)],
['a blank branch tag', {
new_emails: {
...testConfig().new_emails,
branches: {
A: { bento_tag: ' ', label: 'New emails' },
B: { bento_tag: 'ab:no_new_emails', label: 'Old emails' },
},
},
}],
['identical branch tags', {
new_emails: {
...testConfig().new_emails,
branches: {
A: { bento_tag: 'ab:same', label: 'New emails' },
B: { bento_tag: 'ab:same', label: 'Old emails' },
},
},
}],
['identical treatment and control branches', {
new_emails: {
...testConfig().new_emails,
control_branch: 'A',
},
}],
['an unconfigured treatment branch', {
new_emails: {
...testConfig().new_emails,
treatment_branch: 'C',
},
}],
])('rejects %s', async (_label, config) => {
const { validateABTestsConfig } = await loadABTestsModule()
expect(() => validateABTestsConfig(config)).toThrow('Invalid A/B test configuration')
})
it('rejects a tag used by branch A in one experiment and branch B in another', async () => {
const { validateABTestsConfig } = await loadABTestsModule()
const firstTest = testConfig().new_emails
expect(() => validateABTestsConfig({
first_test: {
...firstTest,
branches: {
A: { bento_tag: 'ab:shared', label: 'First treatment' },
B: { bento_tag: 'ab:first_control', label: 'First control' },
},
},
second_test: {
...firstTest,
branches: {
A: { bento_tag: 'ab:second_treatment', label: 'Second treatment' },
B: { bento_tag: 'ab:shared', label: 'Second control' },
},
},
})).toThrow('Invalid A/B test configuration')
})
it.each(['A', 'B'] as const)('rejects a tag reused by branch %s across experiments', async (branch) => {
const { validateABTestsConfig } = await loadABTestsModule()
const firstTest = testConfig().new_emails
const secondBranches = {
A: { bento_tag: 'ab:second_treatment', label: 'Second treatment' },
B: { bento_tag: 'ab:second_control', label: 'Second control' },
}
secondBranches[branch].bento_tag = firstTest.branches[branch].bento_tag
expect(() => validateABTestsConfig({
first_test: firstTest,
second_test: {
...firstTest,
branches: secondBranches,
},
})).toThrow('Invalid A/B test configuration')
})
it('reads complete assignments from the primary when configured tests are intent-gated', async () => {
const { getOrCreateUserABTests } = await loadABTestsModule()
const persisted = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ abtests: persisted, created_via_invite: false, intent: 'ota' }] })
await expect(getOrCreateUserABTests(
context,
USER_ID,
)).resolves.toEqual(persisted)
expect(pgQueryMock).not.toHaveBeenCalled()
expect(getPgClientMock).toHaveBeenCalledOnce()
expect(getPgClientMock).toHaveBeenCalledWith(context, false)
expect(getDrizzleClientMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})
it('bypasses the replica when intent-gated tests require authoritative intent', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const persisted = {
...persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' }),
[BUILDER_TODO_TEST_NAME]: intentAssignment('B'),
}
const replicaAssignments = {
...persisted,
[INTENT_TEST_NAME]: intentAssignment('B'),
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: replicaAssignments, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(getPgClientMock.mock.calls).toEqual([[context, false]])
expect(pgQueryMock).not.toHaveBeenCalled()
expect(drizzleExecuteMock).toHaveBeenCalledTimes(7)
})
it('does not assign an intent-gated test before intent is persisted', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const persisted = persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' })
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({
rows: [{ abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent: null }],
})
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(getPgClientMock.mock.calls).toEqual([[context, false]])
expect(pgQueryMock).not.toHaveBeenCalled()
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})
it('leaves an eligible 0% test unassigned during on-demand lookup', async () => {
const module = await loadABTestsModule()
const configured = module.AB_TESTS_CONFIG[BUILDER_TODO_TEST_NAME]
module.AB_TESTS_CONFIG[BUILDER_TODO_TEST_NAME] = { ...configured, treatment_percentage: 0 }
const persisted = persistedAssignments({ channel: null })
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({
rows: [{ abtests: persisted, created_via_invite: false, email: 'user@example.com', intent: 'builder' }],
})
try {
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
}
finally {
module.AB_TESTS_CONFIG[BUILDER_TODO_TEST_NAME] = configured
}
})
it('preserves an existing assignment after a test is set to 0%', async () => {
const module = await loadABTestsModule()
const configured = module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME]
module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME] = { ...configured, treatment_percentage: 0 }
const persisted = persistedAssignments({ channel: 'A' })
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({
rows: [{ abtests: persisted, created_via_invite: false, email: 'user@example.com', intent: 'ota' }],
})
try {
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
}
finally {
module.AB_TESTS_CONFIG[NEW_CHANNEL_TEST_NAME] = configured
}
})
it('preserves a manual branch A assignment while filling other missing tests', async () => {
const module = await loadABTestsModule()
enableWebNativeTests(module)
const { getOrCreateUserABTests } = module
const manualAssignment = intentAssignment('A')
const existing = {
new_emails: intentAssignment('B'),
[BUILDER_TODO_TEST_NAME]: manualAssignment,
}
const persisted = {
...persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' }),
[BUILDER_TODO_TEST_NAME]: manualAssignment,
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'user@example.com', intent: 'builder' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'user@example.com', intent: 'builder' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
const updateParameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])
const assignmentsJson = updateParameters.find(value => typeof value === 'string' && value.startsWith('{'))
expect(JSON.parse(String(assignmentsJson))[BUILDER_TODO_TEST_NAME]).toEqual(manualAssignment)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, expect.objectContaining({
segments: expect.arrayContaining(['ab:builder_todo_list_v4']),
}), expect.any(AbortSignal))
})
it('clears a pending Bento sync when the user has no deliverable email', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const persisted = {
...persistedAssignments({ development: 'D', emails: 'B', publish: 'B' }),
[INTENT_TEST_NAME]: intentAssignment(),
}
const pendingSync = { cleanup_emails: [], pending: true }
const user = {
abtests: persisted,
abtests_bento_sync: pendingSync,
created_via_invite: false,
email: null,
intent: 'ota',
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({ rows: [user] })
queueBentoSnapshot(user, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
expect(drizzleExecuteMock).toHaveBeenCalledTimes(4)
const updateParameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[3]?.[0])
const stateJson = updateParameters.find(value => typeof value === 'string' && value.includes('"pending"'))
expect(JSON.parse(String(stateJson))).toEqual({ cleanup_emails: [], pending: false })
})
it('creates an intent-gated assignment after an exact intent match is persisted', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const existing = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const persisted = {
...existing,
[INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
vi.useFakeTimers()
vi.setSystemTime(FIXED_DATE)
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(drizzleExecuteMock).toHaveBeenCalledTimes(7)
expect(random).toHaveBeenCalledOnce()
})
it('returns assignments before background Bento reconciliation finishes', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const existing = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const persisted = {
...existing,
[INTENT_TEST_NAME]: intentAssignment(),
}
const user = { abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }
const context = { get: vi.fn(() => 'request-id') } as never
let resolveBento: ((value: boolean) => void) | undefined
const bentoPending = new Promise<boolean>((resolve) => {
resolveBento = resolve
})
let scheduledTask: Promise<unknown> | undefined
backgroundTaskMock.mockImplementationOnce(async (_c, task) => {
scheduledTask = task
})
syncBentoSubscriberTagsMock.mockImplementationOnce(async () => await bentoPending)
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
queueBentoSnapshot(user)
queueBentoSnapshot(user, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
await vi.waitFor(() => expect(syncBentoSubscriberTagsMock).toHaveBeenCalledOnce())
expect(backgroundTaskMock).toHaveBeenCalledOnce()
expect(closeClientMock).not.toHaveBeenCalled()
expect(scheduledTask).toBeDefined()
resolveBento?.(true)
await scheduledTask
expect(closeClientMock).toHaveBeenCalledOnce()
})
it('keeps Bento reconciliation pending when delivery is not configured', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const existing = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const persisted = {
...existing,
[INTENT_TEST_NAME]: intentAssignment(),
}
const pendingSync = {
cleanup_emails: [],
email: 'user@example.com',
pending: true,
}
const user = {
abtests: persisted,
abtests_bento_sync: pendingSync,
created_via_invite: false,
email: 'User@Example.com',
intent: 'ota',
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
queueBentoSnapshot(user)
drizzleExecuteMock.mockResolvedValueOnce({ rows: [user] })
queueBentoSnapshot(user)
syncBentoSubscriberTagsMock.mockResolvedValue(undefined)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledTimes(2)
expect(drizzleExecuteMock).toHaveBeenCalledTimes(7)
expect(drizzleTransactionMock).toHaveBeenCalledTimes(4)
})
it('reconciles Bento after the bounded snapshot transaction commits', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const existing = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const persisted = {
...existing,
[INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
const events: string[] = []
let transactionNumber = 0
drizzleTransactionMock.mockImplementation(async (callback) => {
transactionNumber += 1
const transactionName = ['assignment', 'snapshot', 'verification'][transactionNumber - 1]
events.push(`${transactionName}-started`)
const result = await callback({ execute: drizzleExecuteMock })
events.push(`${transactionName}-committed`)
return result
})
syncBentoSubscriberTagsMock.mockImplementationOnce(async () => {
events.push('bento-synced')
return true
})
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await module.getOrCreateUserABTests(context, USER_ID)
expect(events).toEqual([
'assignment-started',
'assignment-committed',
'snapshot-started',
'snapshot-committed',
'bento-synced',
'verification-started',
'verification-committed',
])
})
it('retries Bento when a newer reconciliation commits during synchronization', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
installIntentTest(module, ['builder'], BUILDER_INTENT_TEST_NAME)
const standardAssignments = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const firstCommit = {
...standardAssignments,
[INTENT_TEST_NAME]: intentAssignment(),
}
const latestCommit = {
...standardAssignments,
[BUILDER_INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: standardAssignments, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: firstCommit }] })
const firstUser = { abtests: firstCommit, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }
const latestUser = { abtests: latestCommit, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }
queueBentoSnapshot(firstUser)
queueBentoSnapshot(latestUser, true)
queueBentoSnapshot(latestUser)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(firstCommit)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledTimes(2)
const [, firstUpdate] = syncBentoSubscriberTagsMock.mock.calls[0]!
const [, latestUpdate, signal] = syncBentoSubscriberTagsMock.mock.calls[1]!
expect(firstUpdate.segments).toContain('ab:intent_targeted')
expect(latestUpdate.segments).toContain('ab:builder_intent_targeted')
expect(latestUpdate.segments).not.toContain('ab:intent_targeted')
expect(latestUpdate.deleteSegments).toEqual(expect.arrayContaining([
'ab:intent_targeted',
'ab:no_intent_targeted',
'ab:no_builder_intent_targeted',
]))
expect(signal).toBeInstanceOf(AbortSignal)
})
it('caps Bento reconciliation attempts when the desired state keeps changing', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const standardAssignments = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const assigned = {
...standardAssignments,
[INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: standardAssignments, created_via_invite: false, email: 'User@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: assigned }] })
for (const branch of ['A', 'B', 'A', 'B'] as const) {
queueBentoSnapshot({
abtests: {
...standardAssignments,
[INTENT_TEST_NAME]: intentAssignment(branch),
},
created_via_invite: false,
email: 'User@Example.com',
intent: 'ota',
})
}
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(assigned)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledTimes(3)
expect(drizzleTransactionMock).toHaveBeenCalledTimes(5)
})
it('clears experiment tags from the previous email before syncing a changed email', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const standardAssignments = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const assigned = {
...standardAssignments,
[INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: standardAssignments, created_via_invite: false, email: 'Old@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: assigned }] })
const oldEmailUser = { abtests: assigned, created_via_invite: false, email: 'Old@Example.com', intent: 'ota' }
const newEmailUser = { abtests: assigned, created_via_invite: false, email: 'New@Example.com', intent: 'ota' }
queueBentoSnapshot(oldEmailUser)
queueBentoSnapshot(newEmailUser, true)
queueBentoSnapshot(newEmailUser)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(assigned)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledTimes(3)
const [, oldEmailUpdate] = syncBentoSubscriberTagsMock.mock.calls[0]!
const [, oldEmailCleanup] = syncBentoSubscriberTagsMock.mock.calls[1]!
const [, newEmailUpdate] = syncBentoSubscriberTagsMock.mock.calls[2]!
expect(oldEmailUpdate).toEqual(expect.objectContaining({
email: 'old@example.com',
segments: expect.arrayContaining(['ab:intent_targeted']),
}))
expect(oldEmailCleanup).toEqual({
deleteSegments: expect.arrayContaining([
'ab:intent_targeted',
'ab:no_intent_targeted',
]),
email: 'old@example.com',
segments: [],
})
expect(newEmailUpdate).toEqual(expect.objectContaining({
email: 'new@example.com',
segments: expect.arrayContaining(['ab:intent_targeted']),
}))
})
it('retries a durably pending email cleanup when assignments are already complete', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const standardAssignments = persistedAssignments({ development: 'D', emails: 'B', publish: 'B' })
const assigned = {
...standardAssignments,
[INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
const oldEmailUser = { abtests: assigned, created_via_invite: false, email: 'Old@Example.com', intent: 'ota' }
const pendingSync = {
cleanup_emails: ['old@example.com'],
email: 'new@example.com',
pending: true,
}
const newEmailUser = {
abtests: assigned,
abtests_bento_sync: pendingSync,
created_via_invite: false,
email: 'New@Example.com',
intent: 'ota',
}
const changedEmailUser = {
abtests: assigned,
created_via_invite: false,
email: 'New@Example.com',
intent: 'ota',
}
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: standardAssignments, created_via_invite: false, email: 'Old@Example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: assigned }] })
queueBentoSnapshot(oldEmailUser)
queueBentoSnapshot(changedEmailUser)
syncBentoSubscriberTagsMock
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(assigned)
drizzleExecuteMock.mockResolvedValueOnce({ rows: [newEmailUser] })
queueBentoSnapshot(newEmailUser)
queueBentoSnapshot(newEmailUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(assigned)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledTimes(4)
expect(syncBentoSubscriberTagsMock.mock.calls[2]?.[1]).toEqual({
deleteSegments: expect.arrayContaining([
'ab:intent_targeted',
'ab:no_intent_targeted',
]),
email: 'old@example.com',
segments: [],
})
expect(syncBentoSubscriberTagsMock.mock.calls[3]?.[1]).toEqual(expect.objectContaining({
email: 'new@example.com',
segments: expect.arrayContaining(['ab:intent_targeted']),
}))
})
it('treats a malformed current branch as unassigned during Bento reconciliation', async () => {
const module = await loadABTestsModule()
installIntentTest(module)
const standardAssignments = {
...persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' }),
[BUILDER_TODO_TEST_NAME]: intentAssignment(),
}
const existing = {
...standardAssignments,
[NEW_CHANNEL_TEST_NAME]: intentAssignment(),
[INTENT_TEST_NAME]: intentAssignment(),
}
const malformedCurrent = {
...standardAssignments,
[INTENT_TEST_NAME]: { assigned_at: FIXED_DATE.toISOString(), branch: 'legacy' },
}
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }] })
.mockResolvedValueOnce({ rows: [{ abtests: standardAssignments }] })
const currentUser = { abtests: malformedCurrent, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(standardAssignments)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, expect.objectContaining({
deleteSegments: expect.arrayContaining(['ab:intent_targeted', 'ab:no_intent_targeted']),
email: 'user@example.com',
}), expect.any(AbortSignal))
})
it.each([
['a changed intent', 'builder'],
['a cleared intent', null],
])('revokes a stale intent-gated assignment after %s', async (_label, intent) => {
const module = await loadABTestsModule()
enableNewChannelTest(module)
installIntentTest(module)
const standardAssignments = persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' })
const expectedAssignments = intent === 'builder'
? { ...standardAssignments, [BUILDER_TODO_TEST_NAME]: intentAssignment() }
: standardAssignments
const retiredAssignment = { assigned_at: FIXED_DATE.toISOString(), branch: 'A' }
const existing = {
...standardAssignments,
[NEW_CHANNEL_TEST_NAME]: intentAssignment(),
[INTENT_TEST_NAME]: intentAssignment(),
retired_experiment: retiredAssignment,
}
const persisted = {
...expectedAssignments,
retired_experiment: retiredAssignment,
}
const context = { get: vi.fn(() => 'request-id') } as never
vi.useFakeTimers()
vi.setSystemTime(FIXED_DATE)
vi.spyOn(Math, 'random').mockReturnValue(0)
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(expectedAssignments)
expect(getPgClientMock.mock.calls).toEqual([[context, false]])
expect(pgQueryMock).not.toHaveBeenCalled()
expect(drizzleExecuteMock).toHaveBeenCalledTimes(7)
const updateParameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])
const assignmentsJson = updateParameters.find(value => typeof value === 'string' && value.startsWith('{'))
expect(JSON.parse(String(assignmentsJson))).toEqual(persisted)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, {
deleteSegments: expect.arrayContaining([
'ab:new_channel',
'ab:no_new_channel',
'ab:new_emails',
'ab:webnativeapp_publish_intent',
'ab:webnativeapp_development_environment',
'ab:intent_targeted',
'ab:no_intent_targeted',
]),
email: 'user@example.com',
segments: expect.arrayContaining([
'ab:no_new_emails',
'ab:no_webnativeapp_publish_intent',
'ab:no_webnativeapp_development_environment',
]),
}, expect.any(AbortSignal))
})
it('revokes the old intent assignment and creates the new intent assignment atomically', async () => {
const module = await loadABTestsModule()
enableNewChannelTest(module)
installIntentTest(module)
installIntentTest(module, ['builder'], BUILDER_INTENT_TEST_NAME)
const standardAssignments = persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' })
const existing = {
...standardAssignments,
[NEW_CHANNEL_TEST_NAME]: intentAssignment(),
[INTENT_TEST_NAME]: intentAssignment(),
}
const persisted = {
...standardAssignments,
[BUILDER_TODO_TEST_NAME]: intentAssignment(),
[BUILDER_INTENT_TEST_NAME]: intentAssignment(),
}
const context = { get: vi.fn(() => 'request-id') } as never
vi.useFakeTimers()
vi.setSystemTime(FIXED_DATE)
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'User@Example.com', intent: 'builder' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(random).toHaveBeenCalledTimes(2)
const updateParameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])
const assignmentsJson = updateParameters.find(value => typeof value === 'string' && value.startsWith('{'))
expect(JSON.parse(String(assignmentsJson))).toEqual(persisted)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, {
deleteSegments: expect.arrayContaining([
'ab:new_channel',
'ab:no_new_channel',
'ab:new_emails',
'ab:webnativeapp_publish_intent',
'ab:webnativeapp_development_environment',
'ab:no_builder_intent_targeted',
'ab:intent_targeted',
'ab:no_intent_targeted',
]),
email: 'user@example.com',
segments: expect.arrayContaining([
'ab:no_new_emails',
'ab:no_webnativeapp_publish_intent',
'ab:no_webnativeapp_development_environment',
'ab:builder_intent_targeted',
]),
}, expect.any(AbortSignal))
})
it.each([false, true])('moves a stored old-email control to new emails and keeps retired v2 data (invited: %s)', async (invited) => {
const module = await loadABTestsModule()
module.AB_TESTS_CONFIG.new_emails = module.validateABTestsConfig(rawConfig).new_emails
const persisted = { new_emails_v2: intentAssignment(), new_emails: intentAssignment() }
const context = { get: vi.fn(() => 'request-id') } as never
vi.useFakeTimers()
vi.setSystemTime(FIXED_DATE)
vi.spyOn(Math, 'random').mockReturnValue(0.99)
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: { new_emails: intentAssignment('B'), new_emails_v2: intentAssignment() }, created_via_invite: invited, email: 'user@example.com', intent: 'ota' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: invited, email: 'user@example.com', intent: 'ota' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(module.getOrCreateUserABTests(context, USER_ID)).resolves.toEqual({ new_emails: intentAssignment() })
const updateParameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])
const assignmentsJson = updateParameters.find(value => typeof value === 'string' && value.startsWith('{'))
expect(JSON.parse(String(assignmentsJson))).toEqual(persisted)
const tagUpdate = syncBentoSubscriberTagsMock.mock.calls[0]?.[1]
expect(tagUpdate?.segments).toEqual(['ab:new_emails'])
expect(tagUpdate?.deleteSegments).toContain('ab:no_new_emails')
expect(tagUpdate?.deleteSegments).not.toContain('ab:new_emails')
})
it('uses the primary database directly when an intent-gated test is configured', async () => {
const { getOrCreateUserABTests } = await loadABTestsModule()
const persisted = persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' })
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ abtests: persisted, created_via_invite: false, email: 'user@example.com' }] })
await expect(getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(getPgClientMock.mock.calls).toEqual([[context, false]])
expect(pgQueryMock).not.toHaveBeenCalled()
expect(drizzleTransactionMock).toHaveBeenCalledOnce()
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})
it('locks the primary user row and assigns only missing tests inside one transaction', async () => {
const module = await loadABTestsModule()
enableWebNativeTests(module)
const { getOrCreateUserABTests } = module
const existing = {
new_emails: { assigned_at: FIXED_DATE.toISOString(), branch: 'B' },
}
const persisted = persistedAssignments({ channel: null, development: 'C', emails: 'B', publish: 'A' })
const context = { get: vi.fn(() => 'request-id') } as never
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
pgQueryMock.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false }] })
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ abtests: existing, created_via_invite: false, email: 'User@Example.com' }] })
.mockResolvedValueOnce({ rows: [{ abtests: persisted }] })
const currentUser = { abtests: persisted, created_via_invite: false, email: 'User@Example.com' }
queueBentoSnapshot(currentUser)
queueBentoSnapshot(currentUser, true)
await expect(getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(getPgClientMock.mock.calls).toEqual([[context, false]])
expect(drizzleTransactionMock).toHaveBeenCalledTimes(3)
expect(drizzleExecuteMock).toHaveBeenCalledTimes(7)
expect(random).toHaveBeenCalledTimes(2)
expect(closeClientMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, {
deleteSegments: expect.arrayContaining([
'ab:no_webnativeapp_development_environment',
'ab:no_webnativeapp_publish_intent',
]),
email: 'user@example.com',
segments: expect.arrayContaining([
'ab:webnativeapp_development_environment',
'ab:webnativeapp_publish_intent',
]),
}, expect.any(AbortSignal))
})
it('rechecks the locked primary row and never regenerates completed assignments', async () => {
const { getOrCreateUserABTests } = await loadABTestsModule()
const partial = {
new_emails: { assigned_at: FIXED_DATE.toISOString(), branch: 'B' },
}
const persisted = persistedAssignments({ channel: null, development: 'D', emails: 'B', publish: 'B' })
const context = { get: vi.fn(() => 'request-id') } as never
const random = vi.spyOn(Math, 'random')
pgQueryMock.mockResolvedValueOnce({ rows: [{ abtests: partial, created_via_invite: false }] })
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ abtests: persisted, created_via_invite: false }] })
await expect(getOrCreateUserABTests(context, USER_ID)).resolves.toEqual(persisted)
expect(drizzleTransactionMock).toHaveBeenCalledOnce()
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(random).not.toHaveBeenCalled()
expect(closeClientMock).toHaveBeenCalledOnce()
})
it.each(['both', 'exploring'])('reconciles existing apps for persisted double-intent A / %s even without an assignment update', async (intent) => {
const { getOrCreateUserABTests } = await loadABTestsModule()
const abtests = { ...persistedAssignments({ channel: null }), todo_list_v4_double_intent: intentAssignment() }
const context = { get: vi.fn(() => 'request-id') } as never
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ abtests, intent, created_via_invite: false }] })
expect(await getOrCreateUserABTests(context, USER_ID)).toEqual(abtests)
expect(drizzleExecuteMock).toHaveBeenCalledTimes(2)
expect(collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])).toContain(USER_ID)
})
it('rejects on-demand assignment when the authenticated profile is missing', async () => {
const { getOrCreateUserABTests } = await loadABTestsModule()
const context = { get: vi.fn(() => 'request-id') } as never
pgQueryMock.mockResolvedValueOnce({ rows: [] })
drizzleExecuteMock.mockResolvedValueOnce({ rows: [] })
await expect(getOrCreateUserABTests(
context,
USER_ID,
)).rejects.toThrow('User not found')
expect(pgQueryMock).not.toHaveBeenCalled()
expect(getPgClientMock.mock.calls).toEqual([[context, false]])
expect(drizzleTransactionMock).toHaveBeenCalledOnce()
expect(closeClientMock).toHaveBeenCalledOnce()
})
})
describe('prospective email cohort persistence', () => {
const context = { get: vi.fn(() => 'request-id') } as never
const createdAt = '2026-10-18T00:00:00.000Z'
beforeEach(async () => {
const module = await loadABTestsModule()
module.AB_TESTS_CONFIG.new_emails_v2 = module.validateABTestsConfig({ ...rawConfig, ...PROSPECTIVE_COHORT_FIXTURE }).new_emails_v2
delete module.AB_TESTS_CONFIG.new_emails
vi.resetAllMocks()
getPgClientMock.mockImplementation(() => ({ connect: pgConnectMock }))
getDrizzleClientMock.mockImplementation(() => ({ transaction: drizzleTransactionMock }))
drizzleTransactionMock.mockImplementation(async callback => await callback({ execute: drizzleExecuteMock }))
drizzleExecuteMock.mockResolvedValue({ rows: [] })
closeClientMock.mockResolvedValue(undefined)
backgroundTaskMock.mockImplementation(async (_c, task) => await task)
syncBentoSubscriberTagsMock.mockResolvedValue(true)
})
afterEach(async () => {
const module = await loadABTestsModule()
module.AB_TESTS_CONFIG.new_emails = module.validateABTestsConfig(rawConfig).new_emails
delete module.AB_TESTS_CONFIG.new_emails_v2
vi.useRealTimers()
vi.restoreAllMocks()
})
it('marks signup enrollment before intent is known without inventing a control assignment', async () => {
const module = await loadABTestsModule()
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ id: USER_ID, created_at: createdAt, created_via_invite: false, abtests: {} }] })
.mockResolvedValueOnce({ rows: [{ abtests: {} }] })
await module.syncNewUserABTests(context, 'signup@example.com', { id: USER_ID, created_via_invite: false })
const parameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])
expect(parameters).toContain('["new_emails_v2"]')
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
expect(drizzleExecuteMock).toHaveBeenCalledTimes(2)
})
it.each([undefined, '2026-10-01T00:00:00.000Z'])('never enrolls a queued old signup with created_at=%s', async (created_at) => {
const module = await loadABTestsModule()
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ id: USER_ID, created_at, created_via_invite: false, abtests: {} }] })
await module.syncNewUserABTests(context, 'signup@example.com', { id: USER_ID, created_via_invite: false })
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})
it.each(['ota', 'builder'])('never enrolls an invited %s signup in v2', async (intent) => {
const module = await loadABTestsModule()
const abtests = intent === 'builder' ? { builder_todo_list_v4: intentAssignment() } : {}
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ id: USER_ID, created_at: createdAt, created_via_invite: true, intent, abtests }] })
await module.syncNewUserABTests(context, 'signup@example.com', { id: USER_ID, created_via_invite: true })
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})
it('does not mark enrollment from an on-demand lookup of a new account', async () => {
const module = await loadABTestsModule()
const abtests = { builder_todo_list_v4: intentAssignment() }
drizzleExecuteMock.mockResolvedValueOnce({ rows: [{ id: USER_ID, created_at: createdAt, created_via_invite: false, intent: 'builder', abtests }] })
expect(await module.getOrCreateUserABTests(context, USER_ID)).toEqual(abtests)
expect(drizzleExecuteMock).toHaveBeenCalledOnce()
})
it('assigns the prerequisite and v2 together using locked current intent when signup arrives late', async () => {
const module = await loadABTestsModule()
vi.useFakeTimers()
vi.setSystemTime(FIXED_DATE)
vi.spyOn(Math, 'random').mockReturnValue(0)
const abtests = { builder_todo_list_v4: intentAssignment(), new_emails_v2: intentAssignment() }
const current = { id: USER_ID, created_at: createdAt, created_via_invite: false, email: 'signup@example.com', intent: 'builder', abtests, abtests_enrolled: ['new_emails_v2'] }
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ ...current, abtests: {}, abtests_enrolled: undefined }] })
.mockResolvedValueOnce({ rows: [{ abtests }] })
queueBentoSnapshot(current)
queueBentoSnapshot(current, true)
await module.syncNewUserABTests(context, 'stale-address@example.com', { id: USER_ID, created_via_invite: false })
const parameters = collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])
expect(parameters).toContain(JSON.stringify(abtests))
expect(parameters).toContain('["new_emails_v2"]')
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, expect.objectContaining({
email: 'signup@example.com',
segments: ['ab:builder_todo_list_v4', 'ab:new_emails_v2'],
}), expect.any(AbortSignal))
expect(backgroundTaskMock).not.toHaveBeenCalled()
expect(pgQueryMock).not.toHaveBeenCalled()
})
it.each(['ota', 'builder'])('removes persisted v2 assignments and both provider tags for invited %s users', async (intent) => {
const module = await loadABTestsModule()
const abtests = intent === 'builder' ? { builder_todo_list_v4: intentAssignment() } : {}
const current = { id: USER_ID, created_at: createdAt, created_via_invite: true, email: 'signup@example.com', intent, abtests, abtests_enrolled: ['new_emails_v2'] }
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ ...current, abtests: { ...abtests, new_emails_v2: intentAssignment() } }] })
.mockResolvedValueOnce({ rows: [{ abtests }] })
queueBentoSnapshot(current)
queueBentoSnapshot(current, true)
expect(await module.getOrCreateUserABTests(context, USER_ID)).toEqual(abtests)
expect(collectSqlParameterValues(drizzleExecuteMock.mock.calls[1]?.[0])).toContain(JSON.stringify(abtests))
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, expect.objectContaining({
segments: intent === 'builder' ? ['ab:builder_todo_list_v4'] : [],
deleteSegments: expect.arrayContaining(['ab:new_emails_v2', 'ab:no_new_emails_v2']),
}), expect.any(AbortSignal))
})
it('reconciles both v2 tags away when the builder prerequisite becomes control', async () => {
const module = await loadABTestsModule()
const abtests = { builder_todo_list_v4: intentAssignment('B') }
const current = { id: USER_ID, created_at: createdAt, created_via_invite: false, email: 'signup@example.com', intent: 'builder', abtests, abtests_enrolled: ['new_emails_v2'] }
drizzleExecuteMock
.mockResolvedValueOnce({ rows: [{ ...current, abtests: { ...abtests, new_emails_v2: intentAssignment() } }] })
.mockResolvedValueOnce({ rows: [{ abtests }] })
queueBentoSnapshot(current)
queueBentoSnapshot(current, true)
expect(await module.getOrCreateUserABTests(context, USER_ID)).toEqual(abtests)
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(context, expect.objectContaining({
segments: ['ab:no_builder_todo_list_v4'],
deleteSegments: expect.arrayContaining(['ab:new_emails_v2', 'ab:no_new_emails_v2']),
}), expect.any(AbortSignal))
})
it('retries a rejected signup tag delivery with the persisted decision', async () => {
const module = await loadABTestsModule()
const current = { id: USER_ID, created_at: createdAt, created_via_invite: false, email: 'signup@example.com', intent: 'builder', abtests: { builder_todo_list_v4: intentAssignment(), new_emails_v2: intentAssignment() }, abtests_enrolled: ['new_emails_v2'], abtests_bento_sync: { pending: true } }
drizzleExecuteMock.mockResolvedValueOnce({ rows: [current] })
queueBentoSnapshot(current)
syncBentoSubscriberTagsMock.mockResolvedValueOnce(false)
await expect(module.syncNewUserABTests(context, current.email, current)).rejects.toThrow('Bento A/B test delivery failed')
expect(closeClientMock).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledOnce()
const retry = { ...current, abtests_bento_sync: { pending: true } }
drizzleExecuteMock.mockResolvedValueOnce({ rows: [retry] })
queueBentoSnapshot(retry)
queueBentoSnapshot(retry, true)
await expect(module.syncNewUserABTests(context, current.email, current)).resolves.toBeUndefined()
expect(syncBentoSubscriberTagsMock.mock.calls[0]?.[1]).toEqual(syncBentoSubscriberTagsMock.mock.calls[1]?.[1])
})
it('leaves a rapidly changing signup pending for queue retry after the bounded sync budget', async () => {
const module = await loadABTestsModule()
const current = { id: USER_ID, created_at: createdAt, created_via_invite: false, email: 'signup@example.com', intent: 'builder', abtests: { builder_todo_list_v4: intentAssignment(), new_emails_v2: intentAssignment() }, abtests_enrolled: ['new_emails_v2'], abtests_bento_sync: { pending: true, email: 'signup@example.com', cleanup_emails: [] } }
drizzleExecuteMock.mockResolvedValueOnce({ rows: [current] })
queueBentoSnapshot(current)
queueBentoSnapshot({ ...current, intent: 'exploring' })
queueBentoSnapshot(current)
queueBentoSnapshot({ ...current, intent: 'exploring' })
await expect(module.syncNewUserABTests(context, current.email, current)).rejects.toThrow('Bento A/B test delivery failed')
expect(syncBentoSubscriberTagsMock).toHaveBeenCalledTimes(3)
})
it('commits accepted alias cleanup progress before a bounded signup retry', async () => {
const module = await loadABTestsModule()
const row: Record<string, unknown> = { id: USER_ID, created_at: createdAt, created_via_invite: false, email: 'signup@example.com', intent: 'builder', abtests: { builder_todo_list_v4: intentAssignment(), new_emails_v2: intentAssignment() }, abtests_enrolled: ['new_emails_v2'], abtests_bento_sync: { pending: true, email: 'signup@example.com', cleanup_emails: ['old1@example.com', 'old2@example.com', 'old3@example.com'] } }
drizzleExecuteMock.mockImplementation(async (query) => {
const state = collectSqlParameterValues(query).find(value => typeof value === 'string' && value.startsWith('{') && value.includes('cleanup_emails'))
if (state)
row.abtests_bento_sync = JSON.parse(String(state))
return { rows: [structuredClone(row)] }
})
await expect(module.syncNewUserABTests(context, 'signup@example.com', { id: USER_ID, created_via_invite: false })).rejects.toThrow('Bento A/B test delivery failed')
expect(row.abtests_bento_sync).toMatchObject({ pending: true, cleanup_emails: [] })
await module.syncNewUserABTests(context, 'signup@example.com', { id: USER_ID, created_via_invite: false })
expect(syncBentoSubscriberTagsMock.mock.calls.map(call => call[1].email)).toEqual([
'old1@example.com',
'old2@example.com',
'old3@example.com',
'signup@example.com',
])
expect(row.abtests_bento_sync).toMatchObject({ pending: false, cleanup_emails: [] })
})
it('serializes concurrent intent resolution and preserves one prerequisite decision', async () => {
const module = await loadABTestsModule()
const row: Record<string, unknown> = { id: USER_ID, created_at: createdAt, created_via_invite: false, email: null, intent: 'builder', abtests: {}, abtests_enrolled: ['new_emails_v2'] }
let lock = Promise.resolve()
drizzleTransactionMock.mockImplementation(async (callback) => {
const previous = lock
let unlock!: () => void
lock = new Promise(resolve => unlock = resolve)
await previous
try {
return await callback({ execute: drizzleExecuteMock })
}
finally { unlock() }
})
drizzleExecuteMock.mockImplementation(async (query) => {
const values = collectSqlParameterValues(query)
const assignments = values.find(value => typeof value === 'string' && value.startsWith('{') && value.includes('branch'))
if (assignments) {
row.abtests = JSON.parse(String(assignments))
return { rows: [{ abtests: row.abtests }] }
}
return { rows: [{ ...row }] }
})
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
const results = await Promise.all([module.getOrCreateUserABTests(context, USER_ID), module.getOrCreateUserABTests(context, USER_ID)])
expect(results[0]).toEqual(results[1])
expect(results[0].new_emails_v2?.branch).toBe('A')
expect(random).toHaveBeenCalledOnce()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})
})