import { describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; mock.module("../scoped-db/locators", () => ({ forgetLocator: async () => undefined })); const { ACCOUNT_SCHEMA } = await import("../scoped-db/schema.gen"); const { ACCOUNT_SCHEMA_EXTRA } = await import("../scoped-db/schema-extra"); const { armsHousekeeping, mark, nextDue, registerAccountTasks, runAccountTasks, SESSION_IDLE_MS, sweepDue } = await import("./housekeeping"); const ran: string[] = []; registerAccountTasks([ { name: "digest", arms: /^\s*INSERT\s+INTO\s+notification_digest_items\b/i, due: (sql) => (sql.exec<{ due: number | null }>("SELECT MIN(flush_after) AS due FROM notification_digest_items").toArray()[0] ?.due as number | null) ?? null, run: async () => void ran.push("digest"), }, { name: "alerts", marks: /^\s*INSERT\s+INTO\s+usage_events\b/i, debounce: 60_000, run: async () => { ran.push("alerts"); throw new Error("boom"); }, }, ]); function store() { const db = new Database(":memory:"); for (const statement of [...ACCOUNT_SCHEMA, ...ACCOUNT_SCHEMA_EXTRA]) db.run(statement); const sql = { // Eager, like Durable Object SQL: a statement runs whether or not its rows are read. exec: (query: string, ...params: Array) => { const rows = db.query(query).all(...params) as never[]; return { toArray: () => rows }; }, }; return { db, sql }; } const NOW = 1_000_000_000_000; describe("account housekeeping", () => { test("inserts into expiring tables arm the alarm; other writes don't", () => { expect(armsHousekeeping("INSERT INTO sessions (token_hash) VALUES (?)")).toBe(true); expect(armsHousekeeping(" insert or ignore into notification_deliveries (message_id) VALUES (?)")).toBe(true); expect(armsHousekeeping("INSERT INTO notification_digest_items (id) VALUES (?)")).toBe(true); expect(armsHousekeeping("INSERT INTO notifications (id) VALUES (?)")).toBe(false); expect(armsHousekeeping("DELETE FROM sessions WHERE id = ?")).toBe(false); }); test("sweeps expired and idle sessions, returns their locators, and schedules the next expiry", () => { const { db, sql } = store(); const insert = db.query( `INSERT INTO sessions (token_hash, id, user_id, created_at, last_seen_at, expires_at) VALUES (?, ?, 'u', 0, ?, ?)`, ); insert.run("expired", "s1", NOW, NOW - 1); insert.run("idle", "s2", NOW - SESSION_IDLE_MS - 1, NOW + 1_000_000); insert.run("live", "s3", NOW, NOW + 3_600_000); const forgotten = sweepDue(sql, NOW); expect(forgotten.map((entry) => entry.key).sort()).toEqual(["expired", "idle"]); expect(db.query("SELECT token_hash FROM sessions").all()).toEqual([{ token_hash: "live" }]); expect(nextDue(sql, NOW)).toBe(NOW + 3_600_000); }); test("nothing waiting means no alarm; due digests are reported for flushing", () => { const { db, sql } = store(); expect(nextDue(sql, NOW)).toBeNull(); db.query( `INSERT INTO notification_digest_items (id, user_id, repo_id, category, title, url, access, flush_after, created_at) VALUES ('d', 'u', NULL, 'deploy', 't', '/x', 'account', ?, 0)`, ).run(NOW - 5); expect(nextDue(sql, NOW)).toBe(NOW + 10_000); }); test("debounced work runs once after its mark, and a failure backs off and keeps the mark", async () => { const { sql } = store(); expect(armsHousekeeping("INSERT INTO usage_events (id) VALUES (?)")).toBe(true); mark(sql, ["alerts"], NOW); mark(sql, ["alerts"], NOW + 30_000); expect(nextDue(sql, NOW)).toBe(NOW + 60_000); const runs = {}; ran.length = 0; await runAccountTasks("u", sql, NOW + 60_000, runs); expect(ran).toEqual(["alerts"]); expect(nextDue(sql, NOW + 60_000, null, runs)).toBe(NOW + 60_000 + 5 * 60_000); }); });