import { beforeEach, describe, expect, it } from "vitest"; import { randomUUID } from "node:crypto"; import { eq, isNull, roles, rolePermissions, subscriptionCredentials, subscriptionPlans, subscriptions, tariffs, users, type Db, } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { listRecycleBin, purge, restore, restoreBlockedReason, softDelete, sweepExpired, } from "./recycle-bin.js"; // Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists // soft-deleted items across kinds, restore brings them back, purge does the real // DELETE (+ children), a restore that would collide with a live row is blocked, and the // retention sweep purges only items past the window. let db: Db; beforeEach(() => { ({ db } = createTestDb()); }); function seedUser(username: string): string { const id = randomUUID(); db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run(); db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run(); return id; } function seedRole(name: string): string { const id = randomUUID(); db.insert(roles).values({ id, name, builtin: 0 }).run(); db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run(); return id; } function seedSubscription(holder: string): string { const id = randomUUID(); db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run(); db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run(); return id; } function seedPlan(planId: string, versions = 2): void { for (let i = 0; i < versions; i++) { db.insert(subscriptionPlans).values({ id: randomUUID(), planId, name: planId, period: "month", pricePerPeriodMinor: 100000, currency: "ALL", effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`, }).run(); } } describe("softDelete + restore + purge", () => { it("stamps the row instead of removing it, and hides it from a live query", () => { const id = seedUser("alice"); expect(softDelete(db, "user", id, "admin-1")).toBe(true); const row = db.select().from(users).where(eq(users.id, id)).get(); expect(row).toBeDefined(); // still there expect(row?.deletedAt).toBeTruthy(); expect(row?.deletedBy).toBe("admin-1"); // A live-only query no longer sees it. expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0); }); it("soft-deleting an already-deleted row is a no-op (returns false)", () => { const id = seedUser("bob"); expect(softDelete(db, "user", id, "a")).toBe(true); expect(softDelete(db, "user", id, "a")).toBe(false); }); it("restore clears the stamps and brings the row back to the live set", () => { const id = seedRole("valet"); softDelete(db, "role", id, "a"); expect(restore(db, "role", id)).toBe(true); const row = db.select().from(roles).where(eq(roles.id, id)).get(); expect(row?.deletedAt).toBeNull(); expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id); }); it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => { const id = seedSubscription("carlos"); // Cannot purge while live (purge only touches soft-deleted rows). expect(purge(db, "subscription", id)).toBe(false); expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined(); softDelete(db, "subscription", id, "a"); expect(purge(db, "subscription", id)).toBe(true); expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined(); // Children gone too. expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0); }); }); describe("versioned plans", () => { it("soft-deletes / restores / purges ALL versions of a planId together", () => { seedPlan("hotel-daily", 3); expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true); expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0); // The bin lists the plan as ONE item, not three. const planItems = listRecycleBin(db).filter((i) => i.kind === "plan"); expect(planItems).toHaveLength(1); expect(planItems[0]?.id).toBe("hotel-daily"); expect(restore(db, "plan", "hotel-daily")).toBe(true); expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3); softDelete(db, "plan", "hotel-daily", "a"); expect(purge(db, "plan", "hotel-daily")).toBe(true); expect(db.select().from(subscriptionPlans).all()).toHaveLength(0); }); }); describe("listRecycleBin", () => { it("collects soft-deleted items across every kind, newest-deleted first", () => { const u = seedUser("dora"); const r = seedRole("guard"); const t = randomUUID(); db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run(); softDelete(db, "user", u, "a"); softDelete(db, "role", r, "a"); softDelete(db, "tariff", t, "a"); const items = listRecycleBin(db); expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]); // Each carries a human label + the deletedAt stamp. expect(items.find((i) => i.kind === "user")?.label).toBe("dora"); expect(items.every((i) => i.deletedAt)).toBe(true); }); }); describe("restoreBlockedReason", () => { // NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live // duplicate can't even be INSERTed while the deleted one exists (the create route // returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a // belt-and-suspenders guard at restore time; verify it returns null in the normal // case (nothing colliding) so a clean restore is never wrongly blocked. it("does not block a normal restore (no live collision)", () => { const u = seedUser("eve"); softDelete(db, "user", u, "a"); expect(restoreBlockedReason(db, "user", u)).toBeNull(); const r = seedRole("cleaner"); softDelete(db, "role", r, "a"); expect(restoreBlockedReason(db, "role", r)).toBeNull(); }); }); describe("sweepExpired (retention)", () => { it("purges items deleted longer than the window ago, keeps recent ones", () => { const old = seedUser("old"); const fresh = seedUser("fresh"); softDelete(db, "user", old, "a"); softDelete(db, "user", fresh, "a"); // Backdate `old`'s deletion to 40 days ago. const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString(); db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run(); const purged = sweepExpired(db, 30); expect(purged.user).toBe(1); expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined(); expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined(); }); it("days <= 0 disables the sweep (keep forever)", () => { const id = seedUser("keeper"); softDelete(db, "user", id, "a"); db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run(); const purged = sweepExpired(db, 0); expect(purged.user).toBe(0); expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined(); }); });