import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { MIGRATIONS_FOLDER } from "../db/fixtures"; import { projectRoots, projects, timelineEvents } from "../db/schema"; import type { CapabilityContext } from "./context"; import { purgeWork } from "./purge"; let sqlite: Database; let db: CapabilityContext["db"]; beforeEach(() => { sqlite = new Database(":memory:"); db = drizzle({ client: sqlite }); migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); }); afterEach(() => { sqlite.close(); }); function context(): CapabilityContext { return { db, userId: "user-1", organizationId: "org-1", deviceIds: ["device-1"], }; } function eventIds(): string[] { return ( sqlite .prepare("SELECT id FROM timeline_events ORDER BY id") .all() as Array<{ id: string; }> ).map((row) => row.id); } async function seedEvents(): Promise { await db.insert(projects).values([ { id: "project-1", name: "Purge", organizationId: "org-1" }, { id: "project-2", name: "Keep", organizationId: "org-1" }, ]); await db.insert(projectRoots).values([ { id: "root-1", projectId: "project-1", path: "/work/purge" }, { id: "root-2", projectId: "project-2", path: "/work/keep" }, ]); await db.insert(timelineEvents).values([ { id: "matching", timestamp: new Date("2026-07-01T12:00:00Z"), source: "file", projectRootId: "root-1", deviceId: "device-1", }, { id: "other-source", timestamp: new Date("2026-07-01T12:00:00Z"), source: "git", projectRootId: "root-1", deviceId: "device-1", }, { id: "other-device", timestamp: new Date("2026-07-01T12:00:00Z"), source: "file", projectRootId: "root-1", deviceId: "device-2", }, { id: "other-project", timestamp: new Date("2026-07-01T12:00:00Z"), source: "file", projectRootId: "root-2", deviceId: "device-1", }, ]); } describe("purgeWork", () => { test("refuses an empty scope and deletes nothing", async () => { await seedEvents(); await expect( purgeWork.execute(context(), { scope: {} }), ).rejects.toMatchObject({ name: "ValidationError", message: expect.stringContaining("Refusing to purge without a scope"), }); expect(eventIds()).toHaveLength(4); }); test("previews without deleting, rejects a wrong token, then deletes only after exact confirmation", async () => { await seedEvents(); const scope = { projectId: "project-1", source: "file" }; // Scope includes BOTH the caller's device events and the org's project // events (buildTimelineScope OR-clause): "matching" (device-1) and // "other-device" (a teammate's device, same org project) are in scope — // purging a project must not leave teammates' or webhook rows behind. const preview = await purgeWork.execute(context(), { scope }); const confirm = "confirm" in preview ? preview.confirm : undefined; expect(preview).toMatchObject({ dryRun: true, wouldDelete: 2, scope, }); expect(confirm).toMatch(/^purge-[0-9a-f]{8}$/); expect(eventIds()).toEqual([ "matching", "other-device", "other-project", "other-source", ]); if (typeof confirm !== "string") { throw new Error("Preview did not return a confirmation token"); } const wrongToken = await purgeWork.execute(context(), { scope: { source: "file", projectId: "project-1" }, confirm: "purge-deadbeef", }); expect(wrongToken).toMatchObject({ dryRun: true, wouldDelete: 2, confirm, }); expect(eventIds()).toHaveLength(4); const deleted = await purgeWork.execute(context(), { scope, confirm, }); expect(deleted).toEqual({ deleted: 2, scope }); expect(eventIds()).toEqual(["other-project", "other-source"]); }); });