import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ProjectError, ProjectService } from "../../apps/api/src/projects.js"; const baseNow = Date.parse("2026-08-02T09:00:00.000Z"); const retentionMs = 720 * 60 * 60 * 1_000; const roots: string[] = []; const services: ProjectService[] = []; function writeEvidence(file: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH; if (!root) return; mkdirSync(root, { recursive: true }); writeFileSync(resolve(root, file), `${JSON.stringify(value, null, 2)}\n`); } afterEach(() => { for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); }); describe("TDD-WP2-PROJ-003-trash-restore-purge", () => { it("keeps exact trash timestamps, blocks restore at twenty, and revokes access at purge_at", () => { let now = baseNow; const root = mkdtempSync(join(tmpdir(), "dada-wp2-03-lifecycle-")); roots.push(root); const service = new ProjectService({ clock: () => now, databasePath: join(root, "dada.sqlite3") }); services.push(service); const ownerId = randomUUID(); const target = service.createProjectForGeneration({ ownerId, prompt: "待恢复项目", ratio: "3:4", status: "failed" }); const firstTrash = service.trashProject(ownerId, target.project.projectId); expect(firstTrash).toMatchObject({ deletedAt: new Date(baseNow).toISOString(), purgeAt: new Date(baseNow + retentionMs).toISOString(), status: "trashed", }); expect(service.activeProjectCount(ownerId)).toBe(0); const fillers = Array.from({ length: 20 }, (_, index) => service.createProjectForGeneration({ ownerId, prompt: `占位项目 ${index}`, ratio: "1:1", status: "failed", })); expect(() => service.restoreProject(ownerId, target.project.projectId)).toThrowError( expect.objectContaining({ code: "project_active_limit" }), ); expect(service.listProjects(ownerId, "trashed")[0]).toMatchObject({ deletedAt: firstTrash.deletedAt, purgeAt: firstTrash.purgeAt, status: "trashed", }); service.trashProject(ownerId, fillers[0].project.projectId); const restored = service.restoreProject(ownerId, target.project.projectId); expect(restored).toMatchObject({ deletedAt: null, purgeAt: null, status: "failed_empty" }); now += 60 * 60 * 1_000; const secondTrash = service.trashProject(ownerId, target.project.projectId); service.purgeProject(ownerId, target.project.projectId); expect(() => service.getProject(ownerId, target.project.projectId)).toThrowError( expect.objectContaining({ code: "project_not_found" }), ); const expiryTarget = service.createProjectForGeneration({ ownerId, prompt: "自然到期项目", ratio: "4:3", status: "failed" }); const expiryTrash = service.trashProject(ownerId, expiryTarget.project.projectId); now = Date.parse(expiryTrash.purgeAt!); expect(service.listProjects(ownerId, "trashed").some((project) => project.projectId === expiryTarget.project.projectId)).toBe(false); expect(() => service.restoreProject(ownerId, expiryTarget.project.projectId)).toThrowError( expect.objectContaining({ code: "project_not_found" }), ); const cleanup = service.database.prepare("SELECT project_id, status FROM project_cleanup_queue ORDER BY created_at, project_id").all(); expect(cleanup).toEqual(expect.arrayContaining([ { project_id: target.project.projectId, status: "pending" }, { project_id: expiryTarget.project.projectId, status: "pending" }, ])); writeEvidence("db-diff.json", { active_limit_restore_preserved_timestamps: true, cleanup_queue: cleanup, explicit_purge_deleted_at: secondTrash.deletedAt, natural_expiry_revoked_at_boundary: true, retention_milliseconds: retentionMs, }); }, 15_000); });