87 lines
4.1 KiB
TypeScript
87 lines
4.1 KiB
TypeScript
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<ProjectError>({ 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<ProjectError>({ 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<ProjectError>({ 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);
|
|
});
|