Skip to content

Commit 4d16e30

Browse files
committed
test(billing): assert observable verdicts and responses instead of mock calls
- The account block, continuation delegation, cache, billing-off and rollover tests assert the verdict or HTTP response. Where behaviour depends on an input, the fake answers by that input, as the real ledger and settlement do. - Pins against real PostgreSQL that a reporting run's top-ups after its window ends are counted in that window, where its request was first charged, and a later run's charges in the next.
1 parent c544dc7 commit 4d16e30

5 files changed

Lines changed: 137 additions & 127 deletions

File tree

‎apps/sim/app/api/billing/update-cost/route.test.ts‎

Lines changed: 58 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -970,18 +970,18 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
970970
it('serves a cached admission to every step and re-reads a refusal', async () => {
971971
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
972972

973-
for (let step = 0; step < 5; step++) {
973+
expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(false)
974+
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
975+
for (let step = 0; step < 4; step++) {
974976
const body = await (await POST(attributedCallback())).json()
975977
expect(body.usageExceeded).toBe(false)
976978
expect(body).not.toHaveProperty('usageUpgrade')
977979
}
978-
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(1)
979980

980981
resetUsageGateCache()
981-
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
982-
await POST(attributedCallback())
983-
await POST(attributedCallback())
984-
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(3)
982+
expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(true)
983+
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
984+
expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(false)
985985
})
986986

987987
it('answers a duplicate retry with the verdict its lost first answer carried', async () => {
@@ -1050,7 +1050,6 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10501050
const body = await (await POST(directCallback())).json()
10511051

10521052
expect(body.usageExceeded).toBe(false)
1053-
expect(billingUsageMonitorMockFns.mockCheckUsageStatus).not.toHaveBeenCalled()
10541053
})
10551054
})
10561055

@@ -1061,56 +1060,70 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10611060
status: 'active',
10621061
seats: 4,
10631062
}
1063+
const ADMITTED_PERIOD = {
1064+
start: new Date('2026-07-01T00:00:00.000Z'),
1065+
end: new Date('2026-08-01T00:00:00.000Z'),
1066+
}
10641067
const CURRENT_PERIOD = {
10651068
start: new Date('2026-08-01T00:00:00.000Z'),
10661069
end: new Date('2026-09-01T00:00:00.000Z'),
10671070
}
10681071

1072+
function admittedWithSource(source: 'stripe' | 'reporting' | 'default') {
1073+
mockToBillingContext.mockReturnValue({
1074+
billingEntity: { type: 'organization', id: 'org-1' },
1075+
billingPeriod: { ...ADMITTED_PERIOD, source },
1076+
})
1077+
}
1078+
1079+
/** Threshold settlement for a payer whose charges belong to `period` refuses any other. */
1080+
function settlesOnlyAgainst(period: typeof ADMITTED_PERIOD) {
1081+
mockCheckAndBillPayerOverageThreshold.mockImplementation(
1082+
async (_payer: unknown, options: { expectedBillingPeriod: typeof ADMITTED_PERIOD }) => {
1083+
if (options.expectedBillingPeriod.start.getTime() !== period.start.getTime()) {
1084+
throw new Error('Settled against a period the charge did not land in')
1085+
}
1086+
}
1087+
)
1088+
}
1089+
10691090
beforeEach(() => {
10701091
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
10711092
mockRequireBillingAttributionHeader.mockReturnValue({
10721093
...CURRENT_ATTRIBUTION,
10731094
payerSubscription: PAYER_SUBSCRIPTION,
10741095
})
1075-
mockToBillingContext.mockReturnValue({
1076-
billingEntity: { type: 'organization', id: 'org-1' },
1077-
billingPeriod: {
1078-
start: new Date('2026-07-01T00:00:00.000Z'),
1079-
end: new Date('2026-08-01T00:00:00.000Z'),
1080-
source: 'stripe',
1081-
},
1082-
})
1096+
// As the ledger behaves: a charge given the payer's subscription lands in its current
1097+
// period, any other stays in the period it was admitted in.
1098+
mockRecordCumulativeUsage.mockImplementation(
1099+
async (params: {
1100+
payerSubscriptionId?: string
1101+
billingPeriod: typeof ADMITTED_PERIOD
1102+
}) => ({
1103+
billed: true,
1104+
delta: 0.5,
1105+
total: 1.5,
1106+
billingPeriod: params.payerSubscriptionId
1107+
? CURRENT_PERIOD
1108+
: { start: params.billingPeriod.start, end: params.billingPeriod.end },
1109+
})
1110+
)
10831111
})
10841112

1085-
it('settles the threshold against the period the charge was stamped into', async () => {
1086-
mockRecordCumulativeUsage.mockResolvedValue({
1087-
billed: true,
1088-
delta: 0.5,
1089-
total: 1.5,
1090-
billingPeriod: CURRENT_PERIOD,
1091-
})
1092-
1093-
const res = await POST(attributedCallback())
1113+
it("records a Stripe payer's charge in its current period and settles it there", async () => {
1114+
admittedWithSource('stripe')
1115+
settlesOnlyAgainst(CURRENT_PERIOD)
10941116

1095-
expect(res.status).toBe(200)
1096-
expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
1097-
expect.objectContaining({ payerSubscriptionId: 'sub-1' })
1098-
)
1099-
expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith(
1100-
{ type: 'organization', id: 'org-1' },
1101-
expect.objectContaining({ expectedBillingPeriod: CURRENT_PERIOD })
1102-
)
1117+
expect((await POST(attributedCallback())).status).toBe(200)
11031118
})
11041119

11051120
it('leaves a period that closed under a recorded charge to the cycle close', async () => {
1121+
admittedWithSource('stripe')
11061122
mockRecordCumulativeUsage.mockResolvedValue({
11071123
billed: true,
11081124
delta: 0.5,
11091125
total: 1.5,
1110-
billingPeriod: {
1111-
start: new Date('2026-07-01T00:00:00.000Z'),
1112-
end: new Date('2026-08-01T00:00:00.000Z'),
1113-
},
1126+
billingPeriod: ADMITTED_PERIOD,
11141127
})
11151128
mockCheckAndBillPayerOverageThreshold.mockRejectedValue(
11161129
new MockThresholdSettlementError('billing_period_elapsed')
@@ -1121,51 +1134,15 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
11211134
expect(res.status).toBe(200)
11221135
})
11231136

1124-
it('keeps a payer whose period is not a Stripe period on its frozen period', async () => {
1125-
mockToBillingContext.mockReturnValue({
1126-
billingEntity: { type: 'organization', id: 'org-1' },
1127-
billingPeriod: {
1128-
start: new Date('2026-07-01T00:00:00.000Z'),
1129-
end: new Date('2026-08-01T00:00:00.000Z'),
1130-
source: 'default',
1131-
},
1132-
})
1133-
mockRecordCumulativeUsage.mockResolvedValue({
1134-
billed: true,
1135-
delta: 0.5,
1136-
total: 0.5,
1137-
billingPeriod: CURRENT_PERIOD,
1138-
})
1139-
1140-
await POST(attributedCallback())
1141-
1142-
expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
1143-
expect.not.objectContaining({ payerSubscriptionId: expect.anything() })
1144-
)
1145-
})
1146-
1147-
it('keeps reporting-window payers on their frozen period', async () => {
1148-
mockToBillingContext.mockReturnValue({
1149-
billingEntity: { type: 'organization', id: 'org-1' },
1150-
billingPeriod: {
1151-
start: new Date('2026-07-01T00:00:00.000Z'),
1152-
end: new Date('2026-08-01T00:00:00.000Z'),
1153-
source: 'reporting',
1154-
},
1155-
})
1156-
mockRecordCumulativeUsage.mockResolvedValue({
1157-
billed: true,
1158-
delta: 0.5,
1159-
total: 0.5,
1160-
billingPeriod: CURRENT_PERIOD,
1161-
})
1162-
1163-
await POST(attributedCallback())
1137+
it.each(['reporting', 'default'] as const)(
1138+
'keeps a payer with a %s period on the period it was admitted in',
1139+
async (source) => {
1140+
admittedWithSource(source)
1141+
settlesOnlyAgainst(ADMITTED_PERIOD)
11641142

1165-
expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
1166-
expect.not.objectContaining({ payerSubscriptionId: expect.anything() })
1167-
)
1168-
})
1143+
expect((await POST(attributedCallback())).status).toBe(200)
1144+
}
1145+
)
11691146
})
11701147

11711148
it.each([
@@ -1280,7 +1257,6 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
12801257
const body = await (await POST(attributedCallback())).json()
12811258

12821259
expect(body.usageExceeded).toBe(false)
1283-
expect(mockRefreshAttributionPeriod).toHaveBeenCalledTimes(2)
12841260
})
12851261

12861262
it('answers not exceeded when the standing read outlasts the callback budget', async () => {
@@ -1341,8 +1317,7 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
13411317
const res = await POST(attributedCallback())
13421318

13431319
expect(res.status).toBe(200)
1344-
await expect(res.json()).resolves.toMatchObject({ usageExceeded: false })
1345-
expect(mockRecordCumulativeUsage).toHaveBeenCalledOnce()
1320+
await expect(res.json()).resolves.toMatchObject({ success: true, usageExceeded: false })
13461321
})
13471322

13481323
it('reports no exceeded usage when billing is disabled', async () => {

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,9 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
400400
})
401401

402402
it('admits a direct-v1 key without Redis while ignoring a local workspace ID', async () => {
403+
mockSerializeAccountBillingDecisionHeader.mockImplementation((decision: object) =>
404+
encodeURIComponent(JSON.stringify(decision))
405+
)
403406
mockGetUserEntityPermissions.mockResolvedValueOnce(null)
404407
mockGetWorkspaceBillingSettings.mockResolvedValueOnce({
405408
billedAccountUserId: 'different-owner',
@@ -426,11 +429,9 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
426429
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
427430
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
428431
expect(mockGetWorkspaceBillingSettings).not.toHaveBeenCalled()
429-
expect(mockSerializeAccountBillingDecisionHeader).toHaveBeenCalledWith({
430-
...ACCOUNT_BILLING_DECISION,
431-
payerSubscriptionId: ACCOUNT_SUBSCRIPTION.id,
432-
})
433-
expect(res.headers.get('x-sim-billing-account-decision')).toBe('serialized-account-decision')
432+
expect(
433+
JSON.parse(decodeURIComponent(res.headers.get('x-sim-billing-account-decision') ?? ''))
434+
).toEqual({ ...ACCOUNT_BILLING_DECISION, payerSubscriptionId: ACCOUNT_SUBSCRIPTION.id })
434435
})
435436

436437
it('fails direct-v1 admission closed when its payer cannot be resolved', async () => {
@@ -740,16 +741,16 @@ describe('validation lifecycle purposes', () => {
740741

741742
it('answers a polled re-check from the cached admission and always re-reads a refusal', async () => {
742743
for (let call = 0; call < 2; call++) queueTableRows(schemaMock.user, [{ id: 'user-1' }])
743-
for (let poll = 0; poll < 3; poll++) {
744+
expect((await POST(request(body, attributedHeaders))).status).toBe(200)
745+
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
746+
for (let poll = 0; poll < 2; poll++) {
744747
expect((await POST(request(body, attributedHeaders))).status).toBe(200)
745748
}
746-
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(1)
747749

748750
resetUsageGateCache()
749-
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
750751
expect((await POST(request(body, attributedHeaders))).status).toBe(402)
751-
expect((await POST(request(body, attributedHeaders))).status).toBe(402)
752-
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(3)
752+
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
753+
expect((await POST(request(body, attributedHeaders))).status).toBe(200)
753754
})
754755

755756
it('refuses a new turn whose usage cannot be read with neutral copy', async () => {
@@ -808,32 +809,30 @@ describe('validation lifecycle purposes', () => {
808809

809810
it('answers repeated direct-v1 continuations from the cached admission and re-reads a refusal', async () => {
810811
for (let call = 0; call < 2; call++) queueTableRows(schemaMock.user, [{ id: 'user-1' }])
811-
for (let leg = 0; leg < 3; leg++) {
812+
expect((await POST(request(body, directHeaders))).status).toBe(200)
813+
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
814+
for (let leg = 0; leg < 2; leg++) {
812815
expect((await POST(request(body, directHeaders))).status).toBe(200)
813816
}
814-
expect(mockCheckUsageStatus).toHaveBeenCalledTimes(1)
815817

816818
resetMidRunUsageCaches()
817-
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
818819
expect((await POST(request(body, directHeaders))).status).toBe(402)
819-
expect((await POST(request(body, directHeaders))).status).toBe(402)
820-
expect(mockCheckUsageStatus).toHaveBeenCalledTimes(3)
820+
mockCheckUsageStatus.mockResolvedValue({ isExceeded: false, currentUsage: 1, limit: 10 })
821+
expect((await POST(request(body, directHeaders))).status).toBe(200)
821822
})
822823

823824
it('never reads the usage gate for an attributed continuation when billing is off', async () => {
824825
setEnvFlags({ isHosted: false, isBillingEnabled: false })
825826
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
826827

827828
expect((await POST(request(body, attributedHeaders))).status).toBe(200)
828-
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
829829
})
830830

831831
it('never reads the ledger for a direct-v1 continuation when billing is off', async () => {
832832
setEnvFlags({ isHosted: false, isBillingEnabled: false })
833833
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
834834

835835
expect((await POST(request(body, directHeaders))).status).toBe(200)
836-
expect(mockCheckUsageStatus).not.toHaveBeenCalled()
837836
})
838837

839838
it('judges a direct-v1 reporting-window run against its admitted window after it ends', async () => {

‎apps/sim/lib/billing/core/billing-attribution.test.ts‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -379,36 +379,40 @@ describe('checkAccountBillingBlocks', () => {
379379
mockCheckBillingEntityBlocked.mockReset().mockResolvedValue({ blocked: false })
380380
})
381381

382-
it('checks both the actor and the exact original payer', async () => {
383-
await expect(checkAccountBillingBlocks(decision)).resolves.toMatchObject({ blocked: false })
384-
expect(mockCheckBillingBlocked).toHaveBeenCalledWith('actor')
385-
expect(mockCheckBillingEntityBlocked).toHaveBeenCalledWith(decision.billingEntity)
386-
})
387-
388-
it('refuses an actor block before reading the payer', async () => {
389-
mockCheckBillingBlocked.mockResolvedValueOnce({ blocked: true })
382+
it('refuses the exact actor and original payer when either is blocked', async () => {
383+
mockCheckBillingBlocked.mockImplementation(async (userId: string) => ({
384+
blocked: userId === 'actor',
385+
}))
390386
await expect(checkAccountBillingBlocks(decision)).resolves.toMatchObject({
391387
blocked: true,
392388
scope: 'actor',
393389
})
394-
expect(mockCheckBillingEntityBlocked).not.toHaveBeenCalled()
395-
})
396390

397-
it('refuses a payer block independently of actor standing', async () => {
398-
mockCheckBillingEntityBlocked.mockResolvedValueOnce({ blocked: true })
391+
mockCheckBillingBlocked.mockResolvedValue({ blocked: false })
392+
mockCheckBillingEntityBlocked.mockImplementation(async (entity: { id: string }) => ({
393+
blocked: entity.id === 'original-payer',
394+
}))
399395
await expect(checkAccountBillingBlocks(decision)).resolves.toMatchObject({
400396
blocked: true,
401397
scope: 'payer',
402398
})
403399
})
404400

405-
it('reads the same personal actor and payer only once', async () => {
406-
await checkAccountBillingBlocks({
407-
...decision,
408-
billingEntity: { type: 'user', id: 'actor' },
401+
it('reports an actor block ahead of a payer block', async () => {
402+
mockCheckBillingBlocked.mockResolvedValue({ blocked: true, message: 'Actor frozen.' })
403+
mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: true, message: 'Payer frozen.' })
404+
await expect(checkAccountBillingBlocks(decision)).resolves.toEqual({
405+
blocked: true,
406+
message: 'Actor frozen.',
407+
scope: 'actor',
409408
})
410-
expect(mockCheckBillingBlocked).toHaveBeenCalledTimes(1)
411-
expect(mockCheckBillingEntityBlocked).not.toHaveBeenCalled()
409+
})
410+
411+
it('answers a personal payer from the actor standing alone', async () => {
412+
mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: true })
413+
await expect(
414+
checkAccountBillingBlocks({ ...decision, billingEntity: { type: 'user', id: 'actor' } })
415+
).resolves.toMatchObject({ blocked: false })
412416
})
413417
})
414418

‎apps/sim/lib/billing/core/usage-log.integration.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { db } from '@sim/db'
99
import * as schema from '@sim/db/schema'
1010
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
1111
import { getPostgresErrorCode } from '@sim/utils/errors'
12+
import { sleep } from '@sim/utils/helpers'
1213
import { generateId } from '@sim/utils/id'
1314
import { sql } from 'drizzle-orm'
1415
import { drizzle } from 'drizzle-orm/postgres-js'
@@ -313,6 +314,31 @@ describe('Cumulative billing with PostgreSQL', () => {
313314
}
314315
)
315316

317+
it("counts a reporting run's top-ups after its window ends in that window, and a later run's charges in the next", async () => {
318+
const payer = { type: 'organization', id: 'payer' } as const
319+
const boundary = new Date(Date.now() + 1500)
320+
const admitted = {
321+
start: new Date(boundary.getTime() - 30 * 24 * 60 * 60 * 1000),
322+
end: boundary,
323+
source: 'reporting' as const,
324+
}
325+
const next = {
326+
start: boundary,
327+
end: new Date(boundary.getTime() + 30 * 24 * 60 * 60 * 1000),
328+
source: 'reporting' as const,
329+
}
330+
const windowTotal = (period: typeof admitted) =>
331+
getBillingPeriodUsageCost(payer, period, undefined, database)
332+
333+
await recordCumulativeUsage({ ...usage(0.4, 'update-cost:long-run'), billingPeriod: admitted })
334+
await sleep(boundary.getTime() - Date.now() + 100)
335+
await recordCumulativeUsage({ ...usage(1, 'update-cost:long-run'), billingPeriod: admitted })
336+
await recordCumulativeUsage({ ...usage(0.25, 'update-cost:next-run'), billingPeriod: next })
337+
338+
expect(await windowTotal(admitted)).toBeCloseTo(1, 9)
339+
expect(await windowTotal(next)).toBeCloseTo(0.25, 9)
340+
})
341+
316342
describe('a request that outlives its billing period', () => {
317343
// Past periods: the old period's row is written under the subscription lock only once
318344
// that period has ended.

0 commit comments

Comments
 (0)