@convex-dev/ai-budget 0.0.2-alpha.14 → 0.0.2-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
1
  import { convexTest } from "convex-test";
2
2
  import { describe, expect, test, vi } from "vitest";
3
+ import shardedCounterTest from "@convex-dev/sharded-counter/test";
4
+ import rateLimiterTest from "@convex-dev/rate-limiter/test";
3
5
  import schema from "./schema";
4
- import { api } from "./_generated/api";
6
+ import { api, internal } from "./_generated/api";
5
7
 
6
8
  // convex-test loads the component's own modules; exclude convex.config (not a
7
9
  // function module) and the test files themselves.
@@ -11,6 +13,13 @@ const modules = import.meta.glob([
11
13
  "!./**/convex.config.ts",
12
14
  ]);
13
15
 
16
+ function initTest() {
17
+ const t = convexTest(schema, modules);
18
+ rateLimiterTest.register(t);
19
+ shardedCounterTest.register(t);
20
+ return t;
21
+ }
22
+
14
23
  const MODEL = "openai/gpt-4o-mini";
15
24
  const msg = (content: string) => [{ role: "user", content }];
16
25
 
@@ -46,7 +55,7 @@ const userOf = (t: any, userId: string) => bucketOf(t, "user", userId);
46
55
 
47
56
  describe("reserve / settle spend caps", () => {
48
57
  test("a daily cap below one request's reservation blocks up front", async () => {
49
- const t = convexTest(schema, modules);
58
+ const t = initTest();
50
59
  // one gpt-4o-mini request reserves ~480_000 nanodollars ($0.00048); a
51
60
  // 1_000-nano ($0.000001) cap can't fit it.
52
61
  await setUserLimits(t, "u", { dailySpendLimitNanos: 1_000 });
@@ -56,7 +65,7 @@ describe("reserve / settle spend caps", () => {
56
65
  });
57
66
 
58
67
  test("reservation is released and settled to the real cost", async () => {
59
- const t = convexTest(schema, modules);
68
+ const t = initTest();
60
69
  await setUserLimits(t, "u", { dailySpendLimitNanos: 1_000_000_000 }); // $1/day
61
70
  const r = await start(t, { userId: "u" });
62
71
  expect(r.allowed).toBe(true);
@@ -79,7 +88,7 @@ async function settleWith(t: any, requestId: any, fields: any) {
79
88
 
80
89
  describe("monthly budgets", () => {
81
90
  test("a tiny monthly cap blocks up front", async () => {
82
- const t = convexTest(schema, modules);
91
+ const t = initTest();
83
92
  await setUserLimits(t, "u", { monthlySpendLimitNanos: 1_000 });
84
93
  const r = await start(t, { userId: "u" });
85
94
  expect(r.allowed).toBe(false);
@@ -89,7 +98,7 @@ describe("monthly budgets", () => {
89
98
 
90
99
  describe("cache-aware pricing", () => {
91
100
  test("cached prompt tokens are billed at the discount, not full input", async () => {
92
- const t = convexTest(schema, modules);
101
+ const t = initTest();
93
102
  const r = await start(t, { userId: "u" });
94
103
  // 1M prompt, ALL cached, 0 completion. gpt-4o-mini input $0.15/Mtok; the
95
104
  // cache default is 10% of input → 0.1 * 150_000_000 = 15_000_000 nano.
@@ -104,7 +113,7 @@ describe("cache-aware pricing", () => {
104
113
  });
105
114
 
106
115
  test("an authoritative gateway cost overrides the token estimate", async () => {
107
- const t = convexTest(schema, modules);
116
+ const t = initTest();
108
117
  const r = await start(t, { userId: "u" });
109
118
  await settleWith(t, r.requestId, {
110
119
  promptTokens: 1_000_000,
@@ -118,7 +127,7 @@ describe("cache-aware pricing", () => {
118
127
 
119
128
  describe("server-tool pricing", () => {
120
129
  test("server-tool uses add a per-call fee on top of tokens", async () => {
121
- const t = convexTest(schema, modules);
130
+ const t = initTest();
122
131
  const r = await start(t, { userId: "u" });
123
132
  // 0 tokens; 3 web searches at the $0.01 default = 30_000_000 nano.
124
133
  await settleWith(t, r.requestId, {
@@ -132,7 +141,7 @@ describe("server-tool pricing", () => {
132
141
  });
133
142
 
134
143
  test("an override price is applied", async () => {
135
- const t = convexTest(schema, modules);
144
+ const t = initTest();
136
145
  await t.mutation(api.lib.setServerToolPrice, {
137
146
  tool: "web_search",
138
147
  nanosPerCall: 12_000_000,
@@ -148,7 +157,7 @@ describe("server-tool pricing", () => {
148
157
  });
149
158
 
150
159
  test("an authoritative cost already includes tool fees (not double-charged)", async () => {
151
- const t = convexTest(schema, modules);
160
+ const t = initTest();
152
161
  const r = await start(t, { userId: "u" });
153
162
  await settleWith(t, r.requestId, {
154
163
  promptTokens: 1_000_000,
@@ -163,7 +172,7 @@ describe("server-tool pricing", () => {
163
172
 
164
173
  describe("cost known up front (image gen, per-call APIs)", () => {
165
174
  test("estimatedCostNanos drives the reservation for a hard cap", async () => {
166
- const t = convexTest(schema, modules);
175
+ const t = initTest();
167
176
  await setUserLimits(t, "u", { dailySpendLimitNanos: 100_000_000 }); // $0.10
168
177
  // A $0.13 image is known before the call; reserving it exceeds the cap,
169
178
  // even though the token estimate for the prompt alone would pass.
@@ -177,7 +186,7 @@ describe("cost known up front (image gen, per-call APIs)", () => {
177
186
  });
178
187
 
179
188
  test("admits when it fits, then settles to the real per-image cost", async () => {
180
- const t = convexTest(schema, modules);
189
+ const t = initTest();
181
190
  await setUserLimits(t, "u", { dailySpendLimitNanos: 500_000_000 });
182
191
  const r = await start(t, {
183
192
  userId: "u",
@@ -194,7 +203,7 @@ describe("cost known up front (image gen, per-call APIs)", () => {
194
203
 
195
204
  describe("async lifecycle (video jobs): begin now, settle later", () => {
196
205
  test("reserveTtlMs is stored, and settle records the real cost", async () => {
197
- const t = convexTest(schema, modules);
206
+ const t = initTest();
198
207
  await setUserLimits(t, "u", { dailySpendLimitNanos: 5_000_000_000 });
199
208
  // Reserve $2 for a long job that will settle minutes later.
200
209
  const r = await start(t, {
@@ -221,7 +230,7 @@ describe("async lifecycle (video jobs): begin now, settle later", () => {
221
230
 
222
231
  describe("durable usage history", () => {
223
232
  test("settled spend lands in a per-day usage row", async () => {
224
- const t = convexTest(schema, modules);
233
+ const t = initTest();
225
234
  const r = await start(t, { userId: "u" });
226
235
  await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
227
236
  const hist = await t.query(api.lib.usageHistory, {
@@ -237,7 +246,7 @@ describe("durable usage history", () => {
237
246
 
238
247
  describe("manual adjustments", () => {
239
248
  test("a credit reduces spend and is logged", async () => {
240
- const t = convexTest(schema, modules);
249
+ const t = initTest();
241
250
  const r = await start(t, { userId: "u" });
242
251
  await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
243
252
  await t.mutation(api.lib.adjustBucket, {
@@ -256,7 +265,7 @@ describe("manual adjustments", () => {
256
265
 
257
266
  describe("threshold alerts", () => {
258
267
  test("crossing warnAtPct returns a notice but still admits", async () => {
259
- const t = convexTest(schema, modules);
268
+ const t = initTest();
260
269
  // One "hi" estimate is ~480_150 nano. Cap 800_000, warn at 50% (400_000).
261
270
  await setUserLimits(t, "u", { dailySpendLimitNanos: 800_000, warnAtPct: 0.5 });
262
271
  const r = await start(t, { userId: "u" });
@@ -267,7 +276,7 @@ describe("threshold alerts", () => {
267
276
 
268
277
  describe("concurrency cap", () => {
269
278
  test("maxConcurrent blocks a second in-flight request", async () => {
270
- const t = convexTest(schema, modules);
279
+ const t = initTest();
271
280
  await setUserLimits(t, "u", { maxConcurrent: 1 });
272
281
  const first = await start(t, { userId: "u" });
273
282
  expect(first.allowed).toBe(true); // reserved, still pending
@@ -278,8 +287,67 @@ describe("concurrency cap", () => {
278
287
  });
279
288
 
280
289
  describe("per-bucket rate limits", () => {
290
+ test("refills continuously and does not consume other buckets on rejection", async () => {
291
+ vi.useFakeTimers();
292
+ try {
293
+ const t = initTest();
294
+ await setUserLimits(t, "u", { requestsPerMinute: 2 });
295
+ await t.mutation(api.lib.setBucketLimits, {
296
+ dimension: "action", value: "busy", requestsPerMinute: 1,
297
+ });
298
+ expect((await start(t, { userId: "other", actionName: "busy" })).allowed).toBe(true);
299
+ expect((await start(t, { userId: "u", actionName: "busy" })).code).toBe("action_rate_limit");
300
+ expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(true);
301
+ expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(true);
302
+ expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(false);
303
+ vi.advanceTimersByTime(30_000);
304
+ expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(true);
305
+ expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(false);
306
+ } finally {
307
+ vi.useRealTimers();
308
+ }
309
+ });
310
+
311
+ test("changing the rate preserves consumption and clamps to capacity", async () => {
312
+ vi.useFakeTimers();
313
+ try {
314
+ const t = initTest();
315
+ await setUserLimits(t, "u", { requestsPerMinute: 2 });
316
+ expect((await start(t, { userId: "u" })).allowed).toBe(true);
317
+ expect((await start(t, { userId: "u" })).allowed).toBe(true);
318
+ await setUserLimits(t, "u", { requestsPerMinute: 4 });
319
+ expect((await start(t, { userId: "u" })).allowed).toBe(false);
320
+ vi.advanceTimersByTime(15_000);
321
+ expect((await start(t, { userId: "u" })).allowed).toBe(true);
322
+ vi.advanceTimersByTime(60_000);
323
+ await setUserLimits(t, "u", { requestsPerMinute: 1 });
324
+ expect((await start(t, { userId: "u" })).allowed).toBe(true);
325
+ expect((await start(t, { userId: "u" })).allowed).toBe(false);
326
+ } finally {
327
+ vi.useRealTimers();
328
+ }
329
+ });
330
+
331
+ test("zero blocks and invalid rates are rejected", async () => {
332
+ const t = initTest();
333
+ await setUserLimits(t, "u", { requestsPerMinute: 0 });
334
+ expect((await start(t, { userId: "u" })).code).toBe("rate_limit");
335
+ for (const requestsPerMinute of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) {
336
+ await expect(setUserLimits(t, "u", { requestsPerMinute })).rejects.toThrow("nonnegative safe integer");
337
+ }
338
+ });
339
+
340
+ test("deleting and recreating a bucket starts a fresh rate balance", async () => {
341
+ const t = initTest();
342
+ await setUserLimits(t, "u", { requestsPerMinute: 1 });
343
+ expect((await start(t, { userId: "u" })).allowed).toBe(true);
344
+ await t.mutation(api.lib.deleteBucket, { dimension: "user", value: "u" });
345
+ await setUserLimits(t, "u", { requestsPerMinute: 1 });
346
+ expect((await start(t, { userId: "u" })).allowed).toBe(true);
347
+ });
348
+
281
349
  test("the existing user rate limit remains compatible", async () => {
282
- const t = convexTest(schema, modules);
350
+ const t = initTest();
283
351
  await setUserLimits(t, "u", { requestsPerMinute: 1 });
284
352
  const first = await start(t, { userId: "u" });
285
353
  expect(first.allowed).toBe(true);
@@ -289,7 +357,7 @@ describe("per-bucket rate limits", () => {
289
357
  });
290
358
 
291
359
  test("an action rate limit blocks the next request for that action", async () => {
292
- const t = convexTest(schema, modules);
360
+ const t = initTest();
293
361
  await t.mutation(api.lib.setBucketLimits, {
294
362
  dimension: "action",
295
363
  value: "ai:summarize",
@@ -303,7 +371,7 @@ describe("per-bucket rate limits", () => {
303
371
  });
304
372
 
305
373
  test("a custom-tag rate limit blocks the next request for that value", async () => {
306
- const t = convexTest(schema, modules);
374
+ const t = initTest();
307
375
  await t.mutation(api.lib.setBucketLimits, {
308
376
  dimension: "customer",
309
377
  value: "acme",
@@ -320,7 +388,7 @@ describe("per-bucket rate limits", () => {
320
388
 
321
389
  describe("tag-filtered request log", () => {
322
390
  test("listRequests filters by a custom tag dimension", async () => {
323
- const t = convexTest(schema, modules);
391
+ const t = initTest();
324
392
  await start(t, { userId: "u", tags: [{ dimension: "customer", value: "acme" }] });
325
393
  await start(t, { userId: "u", tags: [{ dimension: "customer", value: "globex" }] });
326
394
  const acme = await t.query(api.lib.listRequests, {
@@ -332,7 +400,7 @@ describe("tag-filtered request log", () => {
332
400
  });
333
401
 
334
402
  test("blocked attempts appear in the tag-filtered log", async () => {
335
- const t = convexTest(schema, modules);
403
+ const t = initTest();
336
404
  const tags = [{ dimension: "burst", value: "run-1" }];
337
405
  // Cap the tag bucket below one request's reservation so the attempt is
338
406
  // budget-blocked (a persisted rejection).
@@ -352,7 +420,7 @@ describe("tag-filtered request log", () => {
352
420
  });
353
421
 
354
422
  test("persisted blocked attempts don't consume a custom-tag rate limit", async () => {
355
- const t = convexTest(schema, modules);
423
+ const t = initTest();
356
424
  await t.mutation(api.lib.setBucketLimits, {
357
425
  dimension: "customer",
358
426
  value: "acme",
@@ -379,7 +447,7 @@ describe("tag-filtered request log", () => {
379
447
 
380
448
  describe("tagged attribution buckets", () => {
381
449
  test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
382
- const t = convexTest(schema, modules);
450
+ const t = initTest();
383
451
  // A tiny cap on customer "acme" — the user is uncapped.
384
452
  await t.mutation(api.lib.setBucketLimits, {
385
453
  dimension: "customer",
@@ -411,7 +479,7 @@ describe("tagged attribution buckets", () => {
411
479
 
412
480
  describe("D-00 exactly-once settlement", () => {
413
481
  test("a duplicate finishRequest does not double-count", async () => {
414
- const t = convexTest(schema, modules);
482
+ const t = initTest();
415
483
  const r = await start(t, { userId: "u" });
416
484
  await settle(t, r.requestId); // first settle
417
485
  const before = await userOf(t, "u");
@@ -426,7 +494,7 @@ describe("D-00 exactly-once settlement", () => {
426
494
 
427
495
  describe("token quotas", () => {
428
496
  test("a tiny daily token cap blocks (estimate exceeds it)", async () => {
429
- const t = convexTest(schema, modules);
497
+ const t = initTest();
430
498
  await setUserLimits(t, "u", { dailyTokenLimit: 10 });
431
499
  const r = await start(t, { userId: "u" });
432
500
  expect(r.allowed).toBe(false);
@@ -436,7 +504,7 @@ describe("token quotas", () => {
436
504
 
437
505
  describe("soft enforcement", () => {
438
506
  test("over a soft budget: allowed, warned, flagged overBudget", async () => {
439
- const t = convexTest(schema, modules);
507
+ const t = initTest();
440
508
  await setUserLimits(t, "u", {
441
509
  dailySpendLimitNanos: 1, // 1 nanodollar — one estimate blows past it
442
510
  enforcement: "soft",
@@ -451,7 +519,7 @@ describe("soft enforcement", () => {
451
519
 
452
520
  describe("model policy", () => {
453
521
  test("allowlist blocks an off-list model", async () => {
454
- const t = convexTest(schema, modules);
522
+ const t = initTest();
455
523
  await t.mutation(api.lib.setModelPolicy, {
456
524
  mode: "allowlist",
457
525
  models: ["openai/gpt-4o-mini"],
@@ -466,7 +534,7 @@ describe("model policy", () => {
466
534
 
467
535
  describe("D-02 pricing validation", () => {
468
536
  test("setPrice rejects negative rates", async () => {
469
- const t = convexTest(schema, modules);
537
+ const t = initTest();
470
538
  await expect(
471
539
  t.mutation(api.lib.setPrice, {
472
540
  model: "x/y",
@@ -479,7 +547,7 @@ describe("D-02 pricing validation", () => {
479
547
 
480
548
  describe("F-04 fail-closed pricing", () => {
481
549
  test("an unknown model is charged the conservative max, not zero", async () => {
482
- const t = convexTest(schema, modules);
550
+ const t = initTest();
483
551
  const r = await start(t, { userId: "u", model: "made/up-model" });
484
552
  expect(r.allowed).toBe(true);
485
553
  await settle(t, r.requestId, 1_000_000, 1_000_000);
@@ -490,3 +558,152 @@ describe("F-04 fail-closed pricing", () => {
490
558
  expect(req.unpricedModel).toBe(true);
491
559
  });
492
560
  });
561
+
562
+ describe("accounting lifecycle regressions", () => {
563
+ test("old-day and old-month settlements preserve new holds", async () => {
564
+ vi.useFakeTimers();
565
+ try {
566
+ vi.setSystemTime(new Date("2026-09-30T23:59:00Z"));
567
+ const t = initTest();
568
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 1000, monthlySpendLimitNanos: 1000 });
569
+ const old = await start(t, { userId: "u", estimatedCostNanos: 100 });
570
+ vi.setSystemTime(new Date("2026-10-01T00:01:00Z"));
571
+ await start(t, { userId: "u", estimatedCostNanos: 100 });
572
+ await t.mutation(api.lib.finishRequest, { requestId: old.requestId, costNanos: 0 });
573
+ await t.mutation(internal.lib.foldTotals, { requestId: old.requestId });
574
+ const b = await userOf(t, "u");
575
+ expect(b.reservedTodayNanos).toBe(100);
576
+ expect(b.reservedMonthNanos).toBe(100);
577
+ expect(b.reservedTotalNanos).toBe(100);
578
+ expect(b.pendingCount).toBe(1);
579
+ } finally { vi.useRealTimers(); }
580
+ });
581
+
582
+ test("a request admitted before caps were enabled cannot release a later hold", async () => {
583
+ const t = initTest();
584
+ const old = await start(t, { userId: "u", estimatedCostNanos: 100 });
585
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
586
+ await start(t, { userId: "u", estimatedCostNanos: 100 });
587
+ await t.mutation(api.lib.finishRequest, { requestId: old.requestId, costNanos: 0 });
588
+ await t.mutation(internal.lib.foldTotals, { requestId: old.requestId });
589
+ expect((await userOf(t, "u")).reservedTotalNanos).toBe(100);
590
+ });
591
+
592
+ test("expiry releases once, survives retention, and accepts one late charge", async () => {
593
+ vi.useFakeTimers();
594
+ try {
595
+ const t = initTest();
596
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
597
+ const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
598
+ vi.advanceTimersByTime(2 * 60 * 60_000);
599
+ await t.mutation(internal.lib.reconcile, {});
600
+ expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
601
+ await start(t, { userId: "u", estimatedCostNanos: 100 });
602
+ await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
603
+ await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
604
+ await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 999 });
605
+ await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
606
+ const b = await userOf(t, "u");
607
+ expect(b.totalSpendNanos).toBe(75);
608
+ expect(b.totalRequests).toBe(1);
609
+ expect(b.reservedTotalNanos).toBe(100);
610
+ expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(75);
611
+ } finally { vi.useRealTimers(); }
612
+ });
613
+
614
+ test("long TTL jobs cannot hide expired jobs", async () => {
615
+ vi.useFakeTimers();
616
+ try {
617
+ const t = initTest();
618
+ await t.run(async ctx => {
619
+ for (let i = 0; i < 201; i++) await ctx.db.insert("requests", {
620
+ userId: "long", model: MODEL, messages: [], status: "pending",
621
+ expiresAt: Date.now() + 86400_000, heldBucketIds: [],
622
+ });
623
+ });
624
+ const short = await start(t, { userId: "short" });
625
+ vi.advanceTimersByTime(31 * 60_000);
626
+ const result = await t.mutation(internal.lib.reconcile, {});
627
+ expect(result.expired).toBe(1);
628
+ expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
629
+ } finally { vi.useRealTimers(); }
630
+ });
631
+
632
+ test("global accounting includes usage before limits are enabled", async () => {
633
+ const t = initTest();
634
+ const job = await start(t, { userId: "u" });
635
+ await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 100 });
636
+ await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
637
+ expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(100);
638
+ await t.mutation(api.lib.setGlobalLimits, { lifetimeSpendLimitNanos: 100 });
639
+ expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(false);
640
+ });
641
+
642
+ test("settlement does not write admission policy, but changing limits does", async () => {
643
+ const t = initTest();
644
+ const job = await start(t, { userId: "u" });
645
+ const before = await t.run(ctx => ctx.db.query("bucketPolicies").collect());
646
+ await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 100 });
647
+ await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
648
+ expect(await t.run(ctx => ctx.db.query("bucketPolicies").collect())).toEqual(before);
649
+ await setUserLimits(t, "u", { blocked: true });
650
+ expect((await start(t, { userId: "u" })).allowed).toBe(false);
651
+ });
652
+ });
653
+
654
+ test("legacy pending rows acquire deadlines without starving newer expired work", async () => {
655
+ vi.useFakeTimers();
656
+ try {
657
+ const t = initTest();
658
+ await t.run(async ctx => {
659
+ for (let i = 0; i < 201; i++) await ctx.db.insert("requests", {
660
+ userId: "legacy", model: MODEL, messages: [], status: "pending", reserveTtlMs: 86400_000,
661
+ });
662
+ });
663
+ const job = await start(t, { userId: "new" });
664
+ vi.advanceTimersByTime(31 * 60_000);
665
+ expect((await t.mutation(internal.lib.reconcile, {})).expired).toBe(1);
666
+ expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
667
+ await t.mutation(internal.lib.reconcile, {});
668
+ expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
669
+ q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
670
+ } finally { vi.useRealTimers(); }
671
+ });
672
+
673
+ test("retention progresses past unresolved jobs", async () => {
674
+ vi.useFakeTimers();
675
+ try {
676
+ const t = initTest();
677
+ await t.run(async ctx => {
678
+ for (let i = 0; i < 501; i++) await ctx.db.insert("requests", {
679
+ userId: "long", model: MODEL, messages: [], status: "pending", expiresAt: Date.now() + 86400_000,
680
+ });
681
+ });
682
+ const job = await start(t, { userId: "short" });
683
+ await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
684
+ await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
685
+ vi.advanceTimersByTime(2 * 60 * 60_000);
686
+ expect((await t.mutation(internal.lib.reconcile, {})).purged).toBe(1);
687
+ expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
688
+ } finally { vi.useRealTimers(); }
689
+ });
690
+
691
+ test("delayed folding attributes spend to completion day and leaves newer holds intact", async () => {
692
+ vi.useFakeTimers();
693
+ try {
694
+ vi.setSystemTime(new Date("2026-09-30T23:59:00Z"));
695
+ const t = initTest();
696
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
697
+ const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
698
+ await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 50 });
699
+ vi.setSystemTime(new Date("2026-10-01T00:01:00Z"));
700
+ await start(t, { userId: "u", estimatedCostNanos: 100 });
701
+ await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
702
+ const b = await userOf(t, "u");
703
+ expect(b.spendTodayNanos).toBe(0);
704
+ expect(b.reservedTodayNanos).toBe(100);
705
+ const history = await t.query(api.lib.usageHistory, { dimension: "user", value: "u", period: "day" });
706
+ expect(history[0].stamp).toBe("2026-09-30");
707
+ expect(history[0].spendNanos).toBe(50);
708
+ } finally { vi.useRealTimers(); }
709
+ });