import { describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; const ran: string[] = []; mock.module("../ci/cache-api", () => ({ ABANDONED_RESERVATION_MS: 3_600_000, IDLE_EVICTION_MS: 604_800_000, evictIdleCachesForRepo: async () => void ran.push("caches"), })); mock.module("../ci/job-token", () => ({ JOB_TOKEN_MARKER_TTL_MS: 86_400_000, purgeExpiredJobTokensForRepo: async () => void ran.push("job-tokens"), })); mock.module("../ci/runner-api", () => ({ STALE_RUNNER_MS: 180_000, reapStaleRunnerJobsForRepo: async () => void ran.push("stale-runner-jobs"), })); mock.module("../ci/workflow-index", () => ({ fireDueSchedulesForRepo: async () => { ran.push("schedules"); throw new Error("boom"); }, })); mock.module("../attachments", () => ({ ATTACHMENT_ORPHAN_GRACE_MS: 604_800_000, sweepOrphanedAttachmentsForRepo: async () => void ran.push("attachments"), })); mock.module("../git/pull-bridge", () => ({ MAX_CLEANUP_ATTEMPTS: 12, UNSWEPT_CLOSED_PULLS: "i.state = 'closed' AND p.repo_id != p.head_repo_id AND p.refs_swept_at IS NULL", dropHiddenRefsForRepo: async () => void ran.push("hidden-refs"), sweepClosedPullRefsForRepo: async () => void ran.push("closed-pull-refs"), })); mock.module("../integrations/retention", () => ({ WEBHOOK_DELIVERY_RETENTION_MS: 2_592_000_000 })); mock.module("../activity-index", () => ({ indexActivity: async () => void ran.push("activity-index") })); mock.module("../metering/scope-gauges", () => ({ publishScopeGauges: async () => void ran.push("gauges") })); mock.module("../sites/metering", () => ({ ROLLUP_GRACE_MS: 600_000, rollupScopeSiteUsage: async () => void ran.push("site-usage-rollup"), })); mock.module("../sites/directory", () => ({ publishSiteDirectory: async () => void ran.push("site-directory") })); mock.module("../sites/pr-aliases", () => ({ STALE_PULL_ALIASES: `site_pr_aliases a JOIN sites s ON s.id = a.site_id LEFT JOIN issues i ON i.repo_id = s.repo_id AND i.number = a.pr_number WHERE i.id IS NULL OR i.state != 'open'`, sweepClosedPullAliasesForScope: async () => void ran.push("closed-pull-aliases"), })); const { REPO_SCHEMA } = await import("../scoped-db/schema.gen"); const { REPO_SCHEMA_EXTRA } = await import("../scoped-db/schema-extra"); const { armsRepoHousekeeping, nextRepoDue, runRepoHousekeeping } = await import("./housekeeping"); function store() { const db = new Database(":memory:"); for (const statement of [...REPO_SCHEMA, ...REPO_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("repository housekeeping", () => { test("writes to task tables arm the alarm", () => { expect(armsRepoHousekeeping("INSERT INTO workflow_schedules (id) VALUES (?)")).toBe(true); expect(armsRepoHousekeeping("UPDATE workflow_jobs SET status = 'in_progress' WHERE id = ?")).toBe(true); expect(armsRepoHousekeeping("INSERT INTO labels (id) VALUES (?)")).toBe(false); }); test("an idle repository has no alarm; a schedule wakes it at next_run_at", () => { const { db, sql } = store(); expect(nextRepoDue(sql, NOW, {})).toBeNull(); db.query("INSERT INTO activity_index_pending (actor_id, repo_id, at, counted) VALUES ('a', 'r', ?, 1)").run( NOW + 5, ); expect(nextRepoDue(sql, NOW, {})).toBe(NOW + 10_000); db.run("DELETE FROM activity_index_pending"); db.query( "INSERT INTO site_usage (site_id, day, account_id, requests, cpu_ms, bytes_out) VALUES ('s', '2026-10-11', 'u', 1, 0, 0)", ).run(); expect(nextRepoDue(sql, NOW, {})).toBe(Math.max(Date.parse("2026-10-12T00:10:00Z"), NOW + 10_000)); db.run("INSERT INTO site_rollups (day, at) VALUES ('2026-10-11', 0)"); expect(nextRepoDue(sql, NOW, {})).toBeNull(); db.query( "INSERT INTO workflow_schedules (id, repo_id, workflow_path, cron, next_run_at) VALUES ('s', 'r', 'ci.yml', '* * * * *', ?)", ).run(NOW + 60_000); expect(nextRepoDue(sql, NOW, {})).toBe(NOW + 60_000); }); test("runs due tasks only, and backs a failing one off instead of spinning", async () => { const { db, sql } = store(); db.query( "INSERT INTO workflow_schedules (id, repo_id, workflow_path, cron, next_run_at) VALUES ('s', 'r', 'ci.yml', '* * * * *', ?)", ).run(NOW - 1); db.query( "INSERT INTO action_caches (id, repo_id, key, version, ref, r2_key, size, created_at, last_used_at) VALUES ('c', 'r', 'k', 'v', 'refs/heads/main', 'x', 1, ?, ?)", ).run(NOW, NOW); const runs = {}; await runRepoHousekeeping("r", sql, NOW, runs); expect(ran).toEqual(["schedules"]); expect(nextRepoDue(sql, NOW, runs)).toBe(NOW + 5 * 60_000); }); test("a pull alias left after its pull closed is due once the close hook had its chance", () => { const { db, sql } = store(); expect(armsRepoHousekeeping("UPDATE issues SET state = ?, closed_at = ? WHERE id = ?")).toBe(true); db.run( "INSERT INTO sites (id, repo_id, label, source, created_at, updated_at) VALUES ('s1', 'r', 'blog', 'branch', 0, 0)", ); db.query( "INSERT INTO issues (id, repo_id, number, author_id, title, state, is_pull, created_at, updated_at) VALUES ('i7', 'r', 7, 'u', 't', 'open', 1, 0, 0)", ).run(); db.run( "INSERT INTO site_pr_aliases (site_id, label, pr_number, kind, deployment_id, created_at) VALUES ('s1', 'pr-7--blog', 7, 'pr', 'd', 0)", ); expect(nextRepoDue(sql, NOW, {})).toBeNull(); db.query("UPDATE issues SET state = 'closed', closed_at = ? WHERE id = 'i7'").run(NOW); expect(nextRepoDue(sql, NOW, {})).toBe(NOW + 5 * 60_000); }); });