jevstrudel.git / worker / src / budget.test.ts
1import { describe, expect, it } from 'vitest';
2import config from '../wrangler.json';
3import { memoryBudgets, memorySql } from '../test/budget';
4import { balance, createSchema, dailyLimit, nextUtcMidnight, spend, utcDay } from './budget-counter';
5
6const noon = Date.UTC(2026, 8, 25, 12);
7
8describe('spend: one user’s calls, counted per UTC day', () => {
9  it('charges up to the limit, marks the call that spent the last, then refuses', () => {
10    const sql = memorySql();
11    createSchema(sql);
12    const results = Array.from({ length: 5 }, () => spend(sql, 3, noon));
13    expect(results.map((r) => [r.ok, r.used, r.exhausted])).toEqual([
14      [true, 1, false],
15      [true, 2, false],
16      [true, 3, true],
17      [false, 3, false],
18      [false, 3, false],
19    ]);
20    expect(results[4].resetsAt).toBe(Date.UTC(2026, 8, 26));
21    expect(balance(sql, 3, noon)).toEqual({ used: 3, limit: 3, resetsAt: Date.UTC(2026, 8, 26) });
22  });
23
24  it('is exact when many calls race: never over the limit, never a charge lost', async () => {
25    const budgets = memoryBudgets(10, () => noon);
26    const stub = budgets.get(budgets.idFromName('u'));
27    const results = await Promise.all(Array.from({ length: 50 }, () => stub.spend()));
28    expect(results.filter((r) => r.ok)).toHaveLength(10);
29    expect(results.filter((r) => r.exhausted)).toHaveLength(1);
30    expect(new Set(results.filter((r) => r.ok).map((r) => r.used))).toEqual(new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
31    // another user has their own
32    expect((await budgets.get(budgets.idFromName('v')).spend()).used).toBe(1);
33  });
34
35  it('starts again at UTC midnight, and keeps no older day', () => {
36    const sql = memorySql();
37    createSchema(sql);
38    spend(sql, 1, noon);
39    expect(spend(sql, 1, Date.UTC(2026, 8, 25, 23, 59, 59, 999)).ok).toBe(false);
40    const next = spend(sql, 1, Date.UTC(2026, 8, 26));
41    expect(next).toMatchObject({ ok: true, used: 1, exhausted: true });
42    expect(sql.exec('SELECT day FROM spent').toArray()).toEqual([{ day: '2026-09-26' }]);
43  });
44
45  it('refuses when the limit is lowered below what is spent', () => {
46    const sql = memorySql();
47    createSchema(sql);
48    for (let i = 0; i < 5; i++) spend(sql, 10, noon);
49    expect(spend(sql, 3, noon)).toMatchObject({ ok: false, used: 5, limit: 3 });
50  });
51
52  it('charges several calls at once only when all of them fit', () => {
53    const sql = memorySql();
54    createSchema(sql);
55    expect(spend(sql, 5, noon, 3)).toMatchObject({ ok: true, used: 3, exhausted: false });
56    expect(spend(sql, 5, noon, 3)).toMatchObject({ ok: false, used: 3 });
57    expect(spend(sql, 5, noon, 2)).toMatchObject({ ok: true, used: 5, exhausted: true });
58    // more than a whole day's budget never fits, even on a fresh day
59    expect(spend(memorySqlWithSchema(), 2, noon, 3)).toMatchObject({ ok: false, used: 0 });
60    expect(() => spend(sql, 5, noon, 0)).toThrow();
61  });
62
63  it('knows the UTC day and its end', () => {
64    expect(utcDay(Date.UTC(2026, 11, 31, 23, 59))).toBe('2026-12-31');
65    expect(nextUtcMidnight(Date.UTC(2026, 11, 31, 23, 59))).toBe(Date.UTC(2027, 0, 1));
66    expect(nextUtcMidnight(Date.UTC(2026, 8, 25))).toBe(Date.UTC(2026, 8, 26));
67  });
68});
69
70describe('JEV_DAILY_PER_USER', () => {
71  it('is a positive whole number, the same in production and dev', () => {
72    expect(dailyLimit(config.vars.JEV_DAILY_PER_USER)).toBe(config.vars.JEV_DAILY_PER_USER);
73    expect(config.env.dev.vars.JEV_DAILY_PER_USER).toBe(config.vars.JEV_DAILY_PER_USER);
74  });
75
76  it('refuses anything else rather than guess', () => {
77    expect(dailyLimit('25')).toBe(25);
78    for (const bad of [undefined, null, 0, -1, 1.5, '', 'lots', '1e400', Number.NaN]) {
79      expect(() => dailyLimit(bad), String(bad)).toThrow(/positive whole number/);
80    }
81  });
82});
83
84function memorySqlWithSchema() {
85  const sql = memorySql();
86  createSchema(sql);
87  return sql;
88}