Files
tyx_AI_xhs/tests/integration/wp2-03-project-lifecycle.test.ts
T

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);
});