import { beforeEach, describe, expect, mock, test } from "bun:test"; import { fakeD1 } from "../metering/fake-d1.test-helper"; import { scopedTestState } from "./scoped.test-helper"; let d1 = fakeD1(); const scoped = scopedTestState(); scoped.install(); const kv = new Map(); const objects = new Map(); const LIST_LIMIT = 2; const fakeEnv = { PUBLIC_URL: "https://forge.test", get DB() { return d1.binding; }, KV: { get: async (key: string, type?: string) => { const value = kv.get(key) ?? null; return value !== null && type === "json" ? JSON.parse(value) : value; }, put: async (key: string, value: string) => void kv.set(key, value), delete: async (key: string) => void kv.delete(key), }, BUCKET: { list: async (options: { prefix: string; cursor?: string }) => { const keys = [...objects.keys()].filter((key) => key.startsWith(options.prefix)).sort(); const start = Number(options.cursor ?? 0); const page = keys.slice(start, start + LIST_LIMIT); return { objects: page.map((key) => ({ key, uploaded: new Date(objects.get(key) ?? 0) })), truncated: start + LIST_LIMIT < keys.length, cursor: String(start + LIST_LIMIT), }; }, head: async (key: string) => (objects.has(key) ? { uploaded: new Date(objects.get(key) ?? 0) } : null), delete: async (keys: string[]) => void keys.forEach((key) => objects.delete(key)), }, }; mock.module("cloudflare:workers", () => ({ env: fakeEnv, waitUntil: () => undefined })); const notices: string[] = []; mock.module("../notify/deliver", () => ({ notifyUsers: async (input: { title: string }) => void notices.push(input.title), })); const { collectHostingObjects, GC_GRACE_MS } = await import("./gc"); const DAY = 24 * 60 * 60 * 1000; const TICKS = 10; const NOW = Date.parse("2026-10-12T12:00:00Z"); const OLD = NOW - GC_GRACE_MS - DAY; beforeEach(() => { d1 = fakeD1(); kv.clear(); objects.clear(); notices.length = 0; scoped.reset(); d1.db.run("CREATE TABLE cron_locks (name TEXT PRIMARY KEY, holder TEXT, expires_at INTEGER NOT NULL DEFAULT 0)"); scoped.site({ id: "s1", repo_id: "r1" }); scoped.site({ id: "s2", repo_id: "r2" }); for (const [id, site, status] of [ ["sd-active", "s1", "active"], ["sd-failed", "s1", "failed"], ["sd-pinned", "s1", "superseded"], ["sd-other", "s2", "active"], ]) { scoped.insert(site === "s1" ? "r1" : "r2", "site_deployments", { id, site_id: site, label: id, status, created_at: NOW - DAY, }); } for (const [label, deployment] of [ ["abcdef1--blog--alice", "sd-pinned"], ["gone--blog--alice", "sd-deleted"], ]) { scoped .repo("r1") .query( "INSERT INTO site_pr_aliases (site_id, label, pr_number, kind, deployment_id, created_at) VALUES ('s1', ?, 1, 'commit', ?, 0)", ) .run(label, deployment); } kv.set( "sites:manifest:sd-active", JSON.stringify({ files: { "a.html": ["aaa", 1] }, worker: { "index.js": ["www", 1] } }), ); kv.set("sites:manifest:sd-failed", JSON.stringify({ files: { "f.html": ["fff", 1] }, worker: null })); kv.set("sites:manifest:sd-pinned", JSON.stringify({ files: { "p.html": ["ppp", 1] }, worker: null })); kv.set("sites:manifest:sd-other", JSON.stringify({ files: { "index.html": ["ggg", 1] }, worker: null })); for (const hash of ["aaa", "www", "fff", "ppp", "ggg", "zzz"]) objects.set(`pages/objects/${hash}`, OLD); objects.set("pages/objects/new", NOW - DAY); objects.set("sites/uploads/x.tar.gz", OLD); }); describe("hosting object GC", () => { test("sweeps old unreferenced objects across resumable ticks and keeps everything retained or recent", async () => { for (let tick = 0; tick < TICKS; tick += 1) await collectHostingObjects(NOW); expect([...objects.keys()].sort()).toEqual([ "pages/objects/aaa", "pages/objects/ggg", "pages/objects/new", "pages/objects/ppp", "pages/objects/www", "sites/uploads/x.tar.gz", ]); expect(JSON.parse(kv.get("pages:gc:state") ?? "{}")).toEqual({ phase: "idle", nextAt: NOW + DAY }); }); test("aborts without deleting when a recent retained manifest is unreadable, and tells admins", async () => { kv.delete("sites:manifest:sd-other"); for (let tick = 0; tick < TICKS; tick += 1) await collectHostingObjects(NOW); expect(objects.has("pages/objects/zzz")).toBe(true); expect(objects.has("pages/objects/ggg")).toBe(true); expect(notices).toHaveLength(1); }); test("an orphaned deployment missing its manifest for over a week is skipped, not a deadlock", async () => { kv.delete("sites:manifest:sd-other"); scoped .repo("r2") .query("UPDATE site_deployments SET created_at = ? WHERE id = 'sd-other'") .run(NOW - 8 * DAY); for (let tick = 0; tick < TICKS; tick += 1) await collectHostingObjects(NOW); expect(objects.has("pages/objects/zzz")).toBe(false); expect(notices).toEqual([]); }); test("an overlapping run holding the lease does nothing", async () => { d1.db.run("INSERT INTO cron_locks VALUES ('pages-gc', 'other', ?)", [NOW + DAY]); await collectHostingObjects(NOW); expect(kv.has("pages:gc:state")).toBe(false); }); test("waits for the next run once finished", async () => { for (let tick = 0; tick < TICKS; tick += 1) await collectHostingObjects(NOW); objects.set("pages/objects/later", OLD); await collectHostingObjects(NOW + 1); expect(objects.has("pages/objects/later")).toBe(true); }); });