Skip to content

Commit e3c4e4f

Browse files
test(OUT-3617): cover the bank-deposit AB gate end to end
Unit tests for the gate util, the freeze and reconcile gates, and the settings write path + bankDepositEnabled signal. Integration test drives the real webhook -> invoice.service -> DB path to confirm an excluded portal freezes non-batched. The gate is env-parsed at config load, so the integration harness mocks it via a globalThis-pinned allowlist (default = all portals) driven per-test by test/helpers/abTestGate.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e02a235 commit e3c4e4f

7 files changed

Lines changed: 462 additions & 0 deletions

File tree

test/helpers/abTestGate.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Drives the bank-deposit AB gate mocked in test/integration/setup.ts. The mock
2+
// reads its allowlist from a globalThis-pinned holder (the real allowlist is
3+
// env-parsed at module load and can't be varied per-test). `null` = feature on
4+
// for all portals. Always reset in afterEach so state doesn't leak across files.
5+
const AB_GATE_GLOBAL_KEY = '__qbsync_ab_test_gate__'
6+
type ABGate = { allowlist: string[] | null }
7+
const ref = globalThis as unknown as Record<string, ABGate | undefined>
8+
ref[AB_GATE_GLOBAL_KEY] ??= { allowlist: null }
9+
10+
export const abTestGate = {
11+
setAllowlist(portalIds: string[] | null) {
12+
ref[AB_GATE_GLOBAL_KEY]!.allowlist = portalIds
13+
},
14+
reset() {
15+
ref[AB_GATE_GLOBAL_KEY]!.allowlist = null
16+
},
17+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect, afterEach } from 'vitest'
2+
import { db } from '@/db'
3+
import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync'
4+
import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook'
5+
import {
6+
seedHealthyPortal,
7+
seedProductSync,
8+
TEST_PORTAL_ID,
9+
} from '@test/helpers/seed'
10+
import { setupInvoiceCreatedTest } from '@test/helpers/invoiceCreatedTestSetup'
11+
import { postWebhook } from '@test/helpers/webhook'
12+
import { abTestGate } from '@test/helpers/abTestGate'
13+
14+
// The freeze gate must win over the stored flag: a portal outside the AB
15+
// allowlist freezes non-batched even with bankDepositFeeFlag=true, so the whole
16+
// downstream payout/deposit path never engages for it.
17+
describe('POST /api/quickbooks/webhook — invoice.created AB gate on batched intent', () => {
18+
setupInvoiceCreatedTest()
19+
20+
afterEach(() => {
21+
abTestGate.reset()
22+
})
23+
24+
it('freezes non-batched for a portal outside the allowlist despite the flag being on', async () => {
25+
abTestGate.setAllowlist(['some-other-portal'])
26+
await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } })
27+
await seedProductSync()
28+
29+
await postWebhook(invoiceCreatedPayload)
30+
31+
const [row] = await db.select().from(QBInvoiceSync)
32+
expect(row.isBatchedDeposit).toBe(false)
33+
})
34+
35+
it('freezes batched for a portal on the allowlist with the flag on', async () => {
36+
abTestGate.setAllowlist([TEST_PORTAL_ID])
37+
await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } })
38+
await seedProductSync()
39+
40+
await postWebhook(invoiceCreatedPayload)
41+
42+
const [row] = await db.select().from(QBInvoiceSync)
43+
expect(row.isBatchedDeposit).toBe(true)
44+
})
45+
})

test/integration/setup.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ vi.mock('@/utils/sleep', () => ({
103103
sleep: vi.fn().mockResolvedValue(undefined),
104104
}))
105105

106+
// AB gate for the bank deposit rollout. The real allowlist is parsed from env
107+
// at `@/config` module load, so it can't be varied per-test once loaded. We
108+
// mock the gate here (setupFiles runs before any app module binds it) and drive
109+
// it via a globalThis-pinned allowlist. Default `null` = feature on for all
110+
// portals, matching the empty-env behavior so existing tests are unaffected.
111+
// A test opts in by setting `abTestGate.allowlist`; reset it in afterEach.
112+
const AB_GATE_GLOBAL_KEY = '__qbsync_ab_test_gate__'
113+
type ABGate = { allowlist: string[] | null }
114+
const abGateRef = globalThis as unknown as Record<string, ABGate | undefined>
115+
abGateRef[AB_GATE_GLOBAL_KEY] ??= { allowlist: null }
116+
vi.mock('@/utils/abTesting', () => ({
117+
isPortalInBankDepositABTest: (portalId: string) => {
118+
const gate = abGateRef[AB_GATE_GLOBAL_KEY]!
119+
return gate.allowlist === null || gate.allowlist.includes(portalId)
120+
},
121+
}))
122+
106123
// Importing modules that pull `next/server` corrupts NTARH's AsyncLocalStorage.
107124
// Shimming this entry point keeps the next/server import out of the graph.
108125
vi.mock('@/app/api/core/utils/afterIfAvailable', () => ({
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Freeze-point coverage for InvoiceService#readBankDepositFeeFlag — the one
3+
* place invoice creation decides batched intent. A portal outside the AB
4+
* allowlist must freeze non-batched regardless of its stored setting, and must
5+
* not even read the setting. readBankDepositFeeFlag is private, reached via a
6+
* type cast (same approach as invoice.service.docNumber.test.ts).
7+
*/
8+
9+
import { describe, it, expect, vi, beforeEach } from 'vitest'
10+
11+
vi.mock('@sentry/nextjs', () => ({
12+
withScope: vi.fn((cb: (scope: unknown) => void) =>
13+
cb({ setTag: vi.fn(), setExtra: vi.fn(), addEventProcessor: vi.fn() }),
14+
),
15+
captureException: vi.fn(),
16+
captureMessage: vi.fn(),
17+
addBreadcrumb: vi.fn(),
18+
init: vi.fn(),
19+
}))
20+
vi.mock('@/utils/logger', () => ({
21+
default: { info: vi.fn(), error: vi.fn() },
22+
}))
23+
vi.mock('@/utils/copilotAPI', () => ({ CopilotAPI: vi.fn() }))
24+
vi.mock('@/utils/intuitAPI', () => ({
25+
default: vi.fn(),
26+
IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#',
27+
}))
28+
// BaseService imports `@/db`, which initialises postgres at module load.
29+
vi.mock('@/db', () => ({ db: {}, client: {} }))
30+
vi.mock('@/utils/sentry', () => ({
31+
addSyncBreadcrumb: vi.fn(),
32+
captureSyncError: vi.fn(),
33+
}))
34+
// SyncLogService is instantiated in the InvoiceService constructor.
35+
vi.mock('@/app/api/quickbooks/syncLog/syncLog.service', () => ({
36+
SyncLogService: vi.fn(function () {
37+
return {}
38+
}),
39+
}))
40+
41+
const { getOneByPortalId, isPortalInBankDepositABTest } = vi.hoisted(() => ({
42+
getOneByPortalId: vi.fn(),
43+
isPortalInBankDepositABTest: vi.fn(),
44+
}))
45+
vi.mock('@/app/api/quickbooks/setting/setting.service', () => ({
46+
SettingService: vi.fn(function () {
47+
return { getOneByPortalId }
48+
}),
49+
}))
50+
vi.mock('@/utils/abTesting', () => ({ isPortalInBankDepositABTest }))
51+
52+
import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service'
53+
import User from '@/app/api/core/models/User.model'
54+
55+
const stubUser = {
56+
workspaceId: 'test-portal-00000001',
57+
token: 'tkn',
58+
qbConnection: undefined,
59+
} as unknown as User
60+
61+
type WithReadFlag = { readBankDepositFeeFlag: () => Promise<boolean> }
62+
const newSvc = () => new InvoiceService(stubUser) as unknown as WithReadFlag
63+
64+
describe('InvoiceService#readBankDepositFeeFlag — AB freeze gate', () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks()
67+
})
68+
69+
it('freezes non-batched and skips the setting read for an excluded portal', async () => {
70+
isPortalInBankDepositABTest.mockReturnValue(false)
71+
getOneByPortalId.mockResolvedValue({ bankDepositFeeFlag: true })
72+
73+
expect(await newSvc().readBankDepositFeeFlag()).toBe(false)
74+
expect(getOneByPortalId).not.toHaveBeenCalled()
75+
})
76+
77+
it('honors the stored flag for an allowlisted portal', async () => {
78+
isPortalInBankDepositABTest.mockReturnValue(true)
79+
getOneByPortalId.mockResolvedValue({ bankDepositFeeFlag: true })
80+
81+
expect(await newSvc().readBankDepositFeeFlag()).toBe(true)
82+
})
83+
84+
it('defaults to false when an allowlisted portal has no setting row', async () => {
85+
isPortalInBankDepositABTest.mockReturnValue(true)
86+
getOneByPortalId.mockResolvedValue(undefined)
87+
88+
expect(await newSvc().readBankDepositFeeFlag()).toBe(false)
89+
})
90+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* AB-gate coverage for PayoutService#reconcile — the deposit-creating step,
3+
* shared by the payout webhook and the resync cron. An excluded portal must
4+
* short-circuit to { depositId: null } before any token check or QBO call.
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest'
8+
import type { QBPayoutSyncSelectSchemaType } from '@/db/schema/qbPayoutSync'
9+
import type { IntuitAPITokensType } from '@/utils/intuitAPI'
10+
11+
vi.mock('@sentry/nextjs', () => ({
12+
withScope: vi.fn(),
13+
captureException: vi.fn(),
14+
captureMessage: vi.fn(),
15+
addBreadcrumb: vi.fn(),
16+
init: vi.fn(),
17+
}))
18+
// BaseService imports `@/db`, which initialises postgres at module load.
19+
vi.mock('@/db', () => ({ db: {}, client: {} }))
20+
vi.mock('@/utils/copilotAPI', () => ({ CopilotAPI: vi.fn() }))
21+
vi.mock('@/utils/intuitAPI', () => ({ default: vi.fn() }))
22+
vi.mock('@/app/api/quickbooks/syncLog/syncLog.service', () => ({
23+
SyncLogService: vi.fn(function () {
24+
return {}
25+
}),
26+
}))
27+
28+
const { validateAccessToken, isPortalInBankDepositABTest } = vi.hoisted(() => ({
29+
validateAccessToken: vi.fn(),
30+
isPortalInBankDepositABTest: vi.fn(),
31+
}))
32+
vi.mock('@/utils/auth', () => ({ validateAccessToken }))
33+
vi.mock('@/utils/abTesting', () => ({ isPortalInBankDepositABTest }))
34+
35+
import { PayoutService } from '@/app/api/quickbooks/payout/payout.service'
36+
import User from '@/app/api/core/models/User.model'
37+
38+
const stubUser = { workspaceId: 'test-portal-00000001' } as unknown as User
39+
const stubRow = {
40+
payoutId: 'po_123',
41+
} as unknown as QBPayoutSyncSelectSchemaType
42+
const stubTokens = {} as IntuitAPITokensType
43+
44+
describe('PayoutService#reconcile — AB gate', () => {
45+
beforeEach(() => {
46+
vi.clearAllMocks()
47+
})
48+
49+
it('short-circuits to no deposit for an excluded portal without checking the token', async () => {
50+
isPortalInBankDepositABTest.mockReturnValue(false)
51+
52+
const result = await new PayoutService(stubUser).reconcile(
53+
stubRow,
54+
stubTokens,
55+
{ runIdempotencyCheck: true },
56+
)
57+
58+
expect(result).toEqual({ depositId: null })
59+
expect(validateAccessToken).not.toHaveBeenCalled()
60+
})
61+
62+
it('proceeds past the gate for an allowlisted portal', async () => {
63+
isPortalInBankDepositABTest.mockReturnValue(true)
64+
// Force a stop right after the gate so we assert only that it advanced.
65+
validateAccessToken.mockImplementation(() => {
66+
throw new Error('advanced past gate')
67+
})
68+
69+
await expect(
70+
new PayoutService(stubUser).reconcile(stubRow, stubTokens, {
71+
runIdempotencyCheck: true,
72+
}),
73+
).rejects.toThrow('advanced past gate')
74+
expect(validateAccessToken).toHaveBeenCalledTimes(1)
75+
})
76+
})

0 commit comments

Comments
 (0)