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}