| 1 | /** |
| 2 | * A workspace's meter on Node's SQLite standing in for the Durable |
| 3 | * Object's: its projects share one day's share, and on the plan the |
| 4 | * month's total is reported to billing until billing takes it. |
| 5 | */ |
| 6 | import assert from "node:assert/strict"; |
| 7 | import { DatabaseSync } from "node:sqlite"; |
| 8 | import test from "node:test"; |
| 9 | |
| 10 | import { NOTE_AFTER_MS, NOTE_RETRY_MS, WorkspaceMeter, noteAt } from "./meter.ts"; |
| 11 | |
| 12 | function state() { |
| 13 | const db = new DatabaseSync(":memory:"); |
| 14 | const box = { alarm: null as number | null }; |
| 15 | const sql = { |
| 16 | exec(query: string, ...args: unknown[]) { |
| 17 | if (!args.length && query.trim().split(";").filter((q) => q.trim()).length > 1) { |
| 18 | db.exec(query); |
| 19 | return { toArray: () => [] }; |
| 20 | } |
| 21 | const rows = db.prepare(query).all(...(args as never[])); |
| 22 | return { toArray: () => rows }; |
| 23 | }, |
| 24 | }; |
| 25 | const s = { |
| 26 | storage: { |
| 27 | sql, |
| 28 | getAlarm: async () => box.alarm, |
| 29 | setAlarm: async (t: number) => void (box.alarm = t), |
| 30 | }, |
| 31 | } as unknown as DurableObjectState; |
| 32 | return { s, box }; |
| 33 | } |
| 34 | |
| 35 | const NOW = Date.UTC(2026, 9, 13, 12); |
| 36 | |
| 37 | test("a free workspace's projects share one day's share, and a new day starts over", async () => { |
| 38 | const { s, box } = state(); |
| 39 | const meter = new WorkspaceMeter(s); |
| 40 | await meter.add("acme", 60, NOW); |
| 41 | await meter.add("acme", 30, NOW); |
| 42 | assert.equal(meter.todayBytes(NOW), 90); |
| 43 | assert.equal(meter.over(10, 100, NOW), false); |
| 44 | assert.equal(meter.over(11, 100, NOW), true); |
| 45 | assert.equal(meter.over(1e9, null, NOW), false); |
| 46 | assert.equal(meter.over(0, 100, NOW + 24 * 3600_000), false); |
| 47 | // Free: nothing is reported, so no alarm. |
| 48 | assert.equal(box.alarm, null); |
| 49 | }); |
| 50 | |
| 51 | test("the plan last said holds; never said, it is unknown", () => { |
| 52 | const meter = new WorkspaceMeter(state().s); |
| 53 | assert.equal(meter.standing(), null); |
| 54 | meter.setStanding("paid"); |
| 55 | assert.equal(meter.standing(), "paid"); |
| 56 | meter.setStanding("held"); |
| 57 | assert.equal(meter.standing(), "held"); |
| 58 | }); |
| 59 | |
| 60 | test("on the plan, the month's total is reported, again when billing did not take it, and once it has, not again", async () => { |
| 61 | const { s, box } = state(); |
| 62 | const meter = new WorkspaceMeter(s); |
| 63 | meter.setStanding("paid"); |
| 64 | await meter.add("acme", 500, NOW); |
| 65 | assert.equal(box.alarm, NOW + NOTE_AFTER_MS); |
| 66 | const sent: [string, string, number][] = []; |
| 67 | let up = false; |
| 68 | const send = async (workspace: string, month: string, bytes: number) => { |
| 69 | sent.push([workspace, month, bytes]); |
| 70 | if (!up) throw new Error("billing answered 503"); |
| 71 | return true; |
| 72 | }; |
| 73 | box.alarm = null; |
| 74 | await meter.note(send, NOW); |
| 75 | assert.equal(box.alarm, NOW + NOTE_RETRY_MS, "tried again later"); |
| 76 | up = true; |
| 77 | box.alarm = null; |
| 78 | await meter.note(send, NOW); |
| 79 | await meter.note(send, NOW); |
| 80 | assert.deepEqual(sent, [ |
| 81 | ["acme", "2026-10", 500], |
| 82 | ["acme", "2026-10", 500], |
| 83 | ]); |
| 84 | assert.equal(box.alarm, null); |
| 85 | // More arrives: the new total. |
| 86 | await meter.add("acme", 100, NOW); |
| 87 | await meter.note(send, NOW); |
| 88 | assert.deepEqual(sent.at(-1), ["acme", "2026-10", 600]); |
| 89 | }); |
| 90 | |
| 91 | test("the last report of a month goes before it ends", () => { |
| 92 | const end = Date.UTC(2026, 10, 1); |
| 93 | assert.equal(noteAt(end - 2 * 60_000), end - 60_000); |
| 94 | assert.equal(noteAt(NOW), NOW + NOTE_AFTER_MS); |
| 95 | }); |
| 96 | |
| 97 | test("a month's last bytes are reported under that month after it ends; older months are not", async () => { |
| 98 | const { s, box } = state(); |
| 99 | const meter = new WorkspaceMeter(s); |
| 100 | meter.setStanding("paid"); |
| 101 | const end = Date.UTC(2026, 10, 1); |
| 102 | await meter.add("acme", 800, Date.UTC(2026, 8, 20)); |
| 103 | await meter.add("acme", 700, end - 30_000); |
| 104 | const sent: [string, string, number][] = []; |
| 105 | const send = async (workspace: string, month: string, bytes: number) => { |
| 106 | sent.push([workspace, month, bytes]); |
| 107 | return true; |
| 108 | }; |
| 109 | box.alarm = null; |
| 110 | // The alarm fires after the month is over: October's total goes as |
| 111 | // October's, September's (two months back) is left as it was. |
| 112 | await meter.note(send, end + 60_000); |
| 113 | assert.deepEqual(sent, [["acme", "2026-10", 700]]); |
| 114 | await meter.add("acme", 5, end + 120_000); |
| 115 | await meter.note(send, end + 180_000); |
| 116 | assert.deepEqual(sent.at(-1), ["acme", "2026-11", 5]); |
| 117 | assert.equal(sent.length, 2); |
| 118 | }); |