@@ -13,9 +13,10 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
1313import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
1414import {
1515 checkIngestionUsageLimits ,
16- INGESTION_USAGE_GATE_TTL_MS ,
17- resetIngestionUsageGateCache ,
18- } from '@/lib/billing/core/ingestion-usage-gate'
16+ checkSearchUsageLimits ,
17+ resetUsageGateCache ,
18+ USAGE_GATE_TTL_MS ,
19+ } from '@/lib/billing/core/usage-gate-cache'
1920
2021const ATTRIBUTION : BillingAttributionSnapshot = {
2122 actorUserId : 'member-1' ,
@@ -31,9 +32,19 @@ const ATTRIBUTION: BillingAttributionSnapshot = {
3132 payerSubscription : null ,
3233}
3334
35+ const SUBSCRIPTION : BillingAttributionSnapshot [ 'payerSubscription' ] = {
36+ id : 'sub-1' ,
37+ referenceId : 'org-1' ,
38+ plan : 'team' ,
39+ status : 'active' ,
40+ seats : 5 ,
41+ periodStart : '2026-09-01T00:00:00.000Z' ,
42+ periodEnd : '2026-10-01T00:00:00.000Z' ,
43+ }
44+
3445describe ( 'checkIngestionUsageLimits' , ( ) => {
3546 beforeEach ( ( ) => {
36- resetIngestionUsageGateCache ( )
47+ resetUsageGateCache ( )
3748 mockCheck . mockReset ( ) . mockResolvedValue ( { isExceeded : false } )
3849 } )
3950 afterEach ( ( ) => vi . restoreAllMocks ( ) )
@@ -68,13 +79,13 @@ describe('checkIngestionUsageLimits', () => {
6879
6980 /** `lru-cache` reads `performance.now()` and debounces it behind a real 1 ms timer. */
7081 const start = performance . now ( )
71- vi . spyOn ( performance , 'now' ) . mockReturnValue ( start + INGESTION_USAGE_GATE_TTL_MS + 1 )
82+ vi . spyOn ( performance , 'now' ) . mockReturnValue ( start + USAGE_GATE_TTL_MS + 1 )
7283 await sleep ( 5 )
7384 expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
7485 expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
7586 } )
7687
77- it ( 'separates answers by actor, period and payer' , async ( ) => {
88+ it ( 'separates answers by actor, period, period source, payer and plan ' , async ( ) => {
7889 await checkIngestionUsageLimits ( ATTRIBUTION )
7990 await checkIngestionUsageLimits ( { ...ATTRIBUTION , actorUserId : 'member-2' } )
8091 await checkIngestionUsageLimits ( {
@@ -86,7 +97,16 @@ describe('checkIngestionUsageLimits', () => {
8697 billedAccountUserId : 'owner-2' ,
8798 billingEntity : { type : 'user' , id : 'owner-2' } ,
8899 } )
89- expect ( mockCheck ) . toHaveBeenCalledTimes ( 4 )
100+ await checkIngestionUsageLimits ( {
101+ ...ATTRIBUTION ,
102+ billingPeriod : { ...ATTRIBUTION . billingPeriod , source : 'reporting' } ,
103+ } )
104+ await checkIngestionUsageLimits ( { ...ATTRIBUTION , payerSubscription : SUBSCRIPTION } )
105+ await checkIngestionUsageLimits ( {
106+ ...ATTRIBUTION ,
107+ payerSubscription : { ...SUBSCRIPTION , plan : 'enterprise' } ,
108+ } )
109+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 7 )
90110 } )
91111
92112 it ( 'does not cache a failed read' , async ( ) => {
@@ -96,3 +116,45 @@ describe('checkIngestionUsageLimits', () => {
96116 expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
97117 } )
98118} )
119+
120+ describe ( 'checkSearchUsageLimits' , ( ) => {
121+ beforeEach ( ( ) => {
122+ resetUsageGateCache ( )
123+ mockCheck . mockReset ( ) . mockResolvedValue ( { isExceeded : false } )
124+ } )
125+
126+ it ( 'reuses an admission across workspaces of the same payer' , async ( ) => {
127+ await checkSearchUsageLimits ( ATTRIBUTION )
128+ await checkSearchUsageLimits ( { ...ATTRIBUTION , workspaceId : 'workspace-9' } )
129+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 1 )
130+ } )
131+
132+ it ( 're-reads a refusal, so a raised limit applies on the next search' , async ( ) => {
133+ mockCheck . mockResolvedValueOnce ( { isExceeded : true , scope : 'payer' , message : 'over' } )
134+ expect ( ( await checkSearchUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
135+ expect ( ( await checkSearchUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
136+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
137+ } )
138+
139+ it ( 'does not serve a refusal cached by ingestion' , async ( ) => {
140+ mockCheck . mockResolvedValueOnce ( { isExceeded : true , scope : 'payer' , message : 'over' } )
141+ expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
142+ expect ( ( await checkSearchUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
143+ expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
144+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
145+ } )
146+
147+ it ( 'never stores a refusal for ingestion to serve' , async ( ) => {
148+ mockCheck . mockResolvedValueOnce ( { isExceeded : true , scope : 'payer' , message : 'over' } )
149+ expect ( ( await checkSearchUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
150+ expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
151+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
152+ } )
153+
154+ it ( 'does not cache a failed read' , async ( ) => {
155+ mockCheck . mockRejectedValueOnce ( new Error ( 'ledger unavailable' ) )
156+ await expect ( checkSearchUsageLimits ( ATTRIBUTION ) ) . rejects . toThrow ( 'ledger unavailable' )
157+ await checkSearchUsageLimits ( ATTRIBUTION )
158+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
159+ } )
160+ } )
0 commit comments