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[] }>>(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[] }>>(async () => ({ rows: [] })) const drizzleTransactionMock = vi.fn(async (callback: (tx: { execute: typeof drizzleExecuteMock }) => Promise) => { return await callback({ execute: drizzleExecuteMock }) }) return { backgroundTaskMock: vi.fn(async (_c: unknown, task: Promise) => 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>(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 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, 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((resolve) => { resolveBento = resolve }) let scheduledTask: Promise | 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 = { 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 = { 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() }) })