55 billingAttributionMockFns ,
66} from '@sim/testing/mocks/billing-attribution.mock'
77import { sleep } from '@sim/utils/helpers'
8- import { beforeEach , describe , expect , it , vi } from 'vitest'
8+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
99
1010vi . mock ( '@/lib/billing/core/attribution' , ( ) => billingAttributionMock )
1111
@@ -14,13 +14,38 @@ import {
1414 checkExecutionUsageLimits ,
1515 checkIngestionUsageLimits ,
1616 checkSearchUsageLimits ,
17+ NEAR_LIMIT_USAGE_GATE_TTL_MS ,
1718 resetUsageGateCache ,
1819 USAGE_GATE_SETTLE_TIMEOUT_MS ,
1920 USAGE_GATE_TTL_MS ,
2021} from '@/lib/billing/core/usage-gate-cache'
2122
2223const mockCheck = billingAttributionMockFns . mockCheckAttributedUsageLimits
2324
25+ /** Fakes `setTimeout` for {@link advanceClockPast}; install it before the cache is first read. */
26+ function useFakeTimeouts ( ) : void {
27+ vi . useFakeTimers ( { toFake : [ 'setTimeout' , 'clearTimeout' ] } )
28+ }
29+
30+ /**
31+ * Fires the cache's pending clock debounce before restoring real timers; dropped unfired, it would
32+ * pin the module-level cache to this test's spied clock for every later test.
33+ */
34+ function restoreRealTimers ( ) : void {
35+ vi . runOnlyPendingTimers ( )
36+ vi . useRealTimers ( )
37+ }
38+
39+ /**
40+ * `lru-cache` captures `performance` at load, so fake timers cannot move its clock: spy the clock
41+ * forward, then fire the faked 1 ms timer behind which it debounces `performance.now()`.
42+ */
43+ async function advanceClockPast ( ms : number ) : Promise < void > {
44+ const start = performance . now ( )
45+ vi . spyOn ( performance , 'now' ) . mockReturnValue ( start + ms + 1 )
46+ await vi . advanceTimersByTimeAsync ( 1 )
47+ }
48+
2449const ATTRIBUTION : BillingAttributionSnapshot = {
2550 actorUserId : 'member-1' ,
2651 workspaceId : null ,
@@ -74,17 +99,19 @@ describe('checkIngestionUsageLimits', () => {
7499 } )
75100
76101 it ( 'keeps a refusal for the same bounded window, then reads fresh' , async ( ) => {
77- mockCheck . mockResolvedValueOnce ( { isExceeded : true , scope : 'payer' , message : 'over' } )
78- expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
79- expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
80- expect ( mockCheck ) . toHaveBeenCalledTimes ( 1 )
102+ useFakeTimeouts ( )
103+ try {
104+ mockCheck . mockResolvedValueOnce ( { isExceeded : true , scope : 'payer' , message : 'over' } )
105+ expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
106+ expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
107+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 1 )
81108
82- /** `lru-cache` reads `performance.now()` and debounces it behind a real 1 ms timer. */
83- const start = performance . now ( )
84- vi . spyOn ( performance , 'now' ) . mockReturnValue ( start + USAGE_GATE_TTL_MS + 1 )
85- await sleep ( 5 )
86- expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
87- expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
109+ await advanceClockPast ( USAGE_GATE_TTL_MS )
110+ expect ( ( await checkIngestionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
111+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
112+ } finally {
113+ restoreRealTimers ( )
114+ }
88115 } )
89116
90117 it ( 'separates answers by actor, period, period source, payer and plan' , async ( ) => {
@@ -212,3 +239,72 @@ describe('checkExecutionUsageLimits', () => {
212239 }
213240 } )
214241} )
242+
243+ describe ( 'admission lifetime by headroom' , ( ) => {
244+ beforeEach ( ( ) => {
245+ useFakeTimeouts ( )
246+ resetUsageGateCache ( )
247+ mockCheck . mockReset ( )
248+ } )
249+
250+ afterEach ( ( ) => {
251+ restoreRealTimers ( )
252+ } )
253+
254+ it ( 're-reads a free-plan admission after the near-limit window, so spend past the limit is refused' , async ( ) => {
255+ mockCheck
256+ . mockResolvedValueOnce ( { isExceeded : false , payerUsage : { currentUsage : 1 , limit : 5 } } )
257+ . mockResolvedValueOnce ( {
258+ isExceeded : true ,
259+ scope : 'payer' ,
260+ message : 'over' ,
261+ payerUsage : { currentUsage : 5.4 , limit : 5 } ,
262+ } )
263+ expect ( ( await checkExecutionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
264+ expect ( ( await checkExecutionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( false )
265+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 1 )
266+
267+ await advanceClockPast ( NEAR_LIMIT_USAGE_GATE_TTL_MS )
268+ expect ( ( await checkExecutionUsageLimits ( ATTRIBUTION ) ) . isExceeded ) . toBe ( true )
269+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
270+ } )
271+
272+ it ( 're-reads a large pool in the last fifth of its limit' , async ( ) => {
273+ mockCheck . mockResolvedValue ( {
274+ isExceeded : false ,
275+ payerUsage : { currentUsage : 900 , limit : 1000 } ,
276+ } )
277+ await checkExecutionUsageLimits ( ATTRIBUTION )
278+ await advanceClockPast ( NEAR_LIMIT_USAGE_GATE_TTL_MS )
279+ await checkExecutionUsageLimits ( ATTRIBUTION )
280+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
281+ } )
282+
283+ it ( 're-reads when the acting member is near their cap though the pool has room' , async ( ) => {
284+ mockCheck . mockResolvedValue ( {
285+ isExceeded : false ,
286+ payerUsage : { currentUsage : 100 , limit : 1000 } ,
287+ memberUsage : { currentUsage : 48 , limit : 50 } ,
288+ } )
289+ await checkSearchUsageLimits ( ATTRIBUTION )
290+ await advanceClockPast ( NEAR_LIMIT_USAGE_GATE_TTL_MS )
291+ await checkSearchUsageLimits ( ATTRIBUTION )
292+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
293+ } )
294+
295+ it ( 'holds an admission with ample headroom for the full TTL' , async ( ) => {
296+ mockCheck . mockResolvedValue ( {
297+ isExceeded : false ,
298+ payerUsage : { currentUsage : 100 , limit : 1000 } ,
299+ memberUsage : { currentUsage : 10 , limit : null } ,
300+ } )
301+ await checkExecutionUsageLimits ( ATTRIBUTION )
302+ await advanceClockPast ( NEAR_LIMIT_USAGE_GATE_TTL_MS )
303+ await checkExecutionUsageLimits ( ATTRIBUTION )
304+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 1 )
305+
306+ await advanceClockPast ( USAGE_GATE_TTL_MS )
307+ await checkExecutionUsageLimits ( ATTRIBUTION )
308+ expect ( mockCheck ) . toHaveBeenCalledTimes ( 2 )
309+ } )
310+ } )
0 commit comments