feat: complete TASK-WP2-03 project lifecycle

This commit is contained in:
suyx
2026-08-02 20:00:18 +08:00
parent da6fa25e60
commit 9b4860cfc2
17 changed files with 1477 additions and 10 deletions
+5 -3
View File
@@ -114,10 +114,12 @@ describe("TDD-WP0-DATA-002-no-backup-migration route inventory", () => {
it("contains no product backup, archive, business import, migration or recovery route", async () => {
const app = await createApp({ browserGate: false });
try {
const routes = app.printRoutes();
for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "restore", "recovery"]) {
expect(routes.toLowerCase()).not.toContain(forbidden);
await app.ready();
const routes = Object.keys(app.swagger().paths ?? {}).map((route) => route.toLowerCase());
for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "recovery"]) {
expect(routes.some((route) => route.includes(forbidden))).toBe(false);
}
expect(routes.filter((route) => route.includes("restore"))).toEqual(["/api/v1/projects/{projectid}/restore"]);
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NO_TRANSFER;
if (evidenceDirectory) {
mkdirSync(evidenceDirectory, { recursive: true });
@@ -0,0 +1,91 @@
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 { createApp } from "../../apps/api/src/app.js";
import { ProjectService } from "../../apps/api/src/projects.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const now = Date.parse("2026-08-02T09:00:00.000Z");
const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const roots: string[] = [];
const registrations: RegistrationService[] = [];
const projects: ProjectService[] = [];
function writeEvidence(value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH;
if (!root) return;
mkdirSync(root, { recursive: true });
writeFileSync(resolve(root, "response.json"), `${JSON.stringify(value, null, 2)}\n`);
}
afterEach(() => {
for (const project of projects.splice(0)) project.close();
for (const registration of registrations.splice(0)) registration.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP2-PROJ-003 lifecycle API", () => {
it("exposes owner-only trash, restore, and irreversible purge routes", async () => {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-03-api-"));
roots.push(root);
const databasePath = join(root, "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x71), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
invitePepper: Buffer.alloc(32, 0x72), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x73),
});
registrations.push(registration);
const projectService = new ProjectService({ clock: () => now, databasePath });
projects.push(projectService);
const ownerId = randomUUID();
registration.database.prepare(`INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
) VALUES (?, 'trash@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Trash User', '@trash_user')").run(ownerId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(ownerId, now);
const session = registration.issueAuthenticatedSession(ownerId, "user");
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, projects: projectService, registration });
const cookie = `dada_session=${session.sessionToken}`;
const sessionResponse = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/auth/session" });
const csrf = sessionResponse.json().csrf_token as string;
const mutationHeaders = { ...baseHeaders, cookie, "x-csrf-token": csrf };
const target = projectService.createProjectForGeneration({ ownerId, prompt: "API 回收站", ratio: "3:4", status: "failed" });
const lifecycle = (action: "trash" | "restore" | "purge") => app.inject({
headers: mutationHeaders, method: "POST", url: `/api/v1/projects/${target.project.projectId}/${action}`,
});
const trashed = await lifecycle("trash");
expect(trashed.statusCode).toBe(200);
expect(trashed.json()).toMatchObject({ project_id: target.project.projectId, status: "trashed" });
const firstTimes = { deleted_at: trashed.json().deleted_at, purge_at: trashed.json().purge_at };
for (let index = 0; index < 20; index += 1) {
projectService.createProjectForGeneration({ ownerId, prompt: `API 名额 ${index}`, ratio: "1:1", status: "failed" });
}
const blocked = await lifecycle("restore");
expect(blocked.statusCode).toBe(409);
expect(blocked.body).toBe("null");
const trashedList = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/projects?status=trashed" });
expect(trashedList.json().projects[0]).toMatchObject(firstTimes);
const fillerId = projectService.listProjects(ownerId, "active")[0].projectId;
projectService.trashProject(ownerId, fillerId);
const restored = await lifecycle("restore");
expect(restored.statusCode).toBe(200);
expect(restored.json()).toMatchObject({ deleted_at: null, purge_at: null, status: "active" });
await lifecycle("trash");
const purged = await lifecycle("purge");
expect(purged.statusCode).toBe(200);
expect(purged.json()).toEqual({ project_id: target.project.projectId, status: "purged" });
const hidden = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: `/api/v1/projects/${target.project.projectId}` });
const cannotRestore = await lifecycle("restore");
expect(hidden.statusCode).toBe(404);
expect(cannotRestore.statusCode).toBe(404);
writeEvidence({ blocked_restore: blocked.statusCode, first_times: firstTimes, hidden_after_purge: hidden.statusCode, purged: purged.json(), restored: restored.json() });
await app.close();
}, 20_000);
});
+96
View File
@@ -0,0 +1,96 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
let vite: ViteDevServer;
let webUrl: string;
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
const targetId = "00000000-0000-4000-8000-000000000501";
const doomedId = "00000000-0000-4000-8000-000000000502";
const deletedAt = "2026-08-02T09:00:00.000Z";
const purgeAt = "2026-09-01T09:00:00.000Z";
const session = {
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
csrf_token: "csrf-project-trash-fixture-000000000000000000000000000000000",
expires_at: "2026-09-02T09:00:00.000Z",
user: { creator_name: "Trash User", role: "user", social_id: "@trash_user", status: "active", user_id: "00000000-0000-4000-8000-000000000503" },
};
function summary(projectId: string, name: string, status: "active" | "trashed") {
return {
current_image_id: null, deleted_at: status === "trashed" ? deletedAt : null, name, project_id: projectId,
purge_at: status === "trashed" ? purgeAt : null, ratio: "3:4", state_version: 2, status,
successful_image_count: 0, updated_at: deletedAt,
};
}
test("TDD-WP2-PROJ-003 exposes safe trash, blocked restore, restore, and permanent purge actions", async ({ page }) => {
let activeCount = 20;
let targetStatus: "active" | "trashed" = "active";
let restored = false;
let purged = false;
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/projects?status=*", (route) => {
const status = new URL(route.request().url()).searchParams.get("status");
const projects = status === "active"
? targetStatus === "active" ? [summary(targetId, "日落海报", "active")] : []
: [
...(targetStatus === "trashed" && !restored ? [summary(targetId, "日落海报", "trashed")] : []),
...(!purged ? [summary(doomedId, "待永久删除", "trashed")] : []),
];
return route.fulfill({ body: JSON.stringify({ active_count: activeCount, active_limit: 20, projects }), contentType: "application/json", status: 200 });
});
await page.route(`**/api/v1/projects/${targetId}/trash`, (route) => {
targetStatus = "trashed";
activeCount = 19;
return route.fulfill({ body: JSON.stringify({ ...summary(targetId, "日落海报", "trashed") }), contentType: "application/json", status: 200 });
});
await page.route(`**/api/v1/projects/${targetId}/restore`, (route) => {
restored = true;
targetStatus = "active";
activeCount = 20;
return route.fulfill({ body: JSON.stringify({ ...summary(targetId, "日落海报", "active") }), contentType: "application/json", status: 200 });
});
await page.route(`**/api/v1/projects/${doomedId}/purge`, (route) => {
purged = true;
return route.fulfill({ body: JSON.stringify({ project_id: doomedId, status: "purged" }), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/app/projects`);
await page.getByRole("button", { name: "移入回收站:日落海报" }).click();
await expect(page.getByText("项目已移入回收站")).toBeVisible();
activeCount = 20;
await page.getByRole("tab", { name: "回收站" }).click();
await expect(page.getByRole("button", { name: "恢复项目:日落海报" })).toBeDisabled();
await expect(page.getByText("活动项目已达 20 个,请先释放名额")).toBeVisible();
activeCount = 19;
await page.getByRole("tab", { name: "项目", exact: true }).click();
await page.getByRole("tab", { name: "回收站" }).click();
await page.getByRole("button", { name: "恢复项目:日落海报" }).click();
await expect(page.getByText("项目已恢复")).toBeVisible();
await page.getByRole("button", { name: "永久删除:待永久删除" }).click();
await expect(page.getByRole("dialog", { name: "永久删除项目" })).toBeVisible();
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH;
if (evidenceRoot) {
const directory = resolve(evidenceRoot, "screenshots");
mkdirSync(directory, { recursive: true });
await page.screenshot({ fullPage: true, path: resolve(directory, "trash-confirmation.png") });
}
await page.getByRole("button", { name: "确认永久删除" }).click();
await expect(page.getByText("项目已永久删除,物理清理将在后台完成")).toBeVisible();
expect(restored).toBe(true);
expect(purged).toBe(true);
});
@@ -0,0 +1,86 @@
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);
});
@@ -0,0 +1,85 @@
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 { Readable } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
import { ProjectService } from "../../apps/api/src/projects.js";
import { ProjectPurgeCleanup } from "../../apps/worker/src/project-purge-cleanup.js";
const now = Date.parse("2026-08-02T09:00:00.000Z");
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
const roots: string[] = [];
function writeEvidence(value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECT_TRASH;
if (!root) return;
mkdirSync(root, { recursive: true });
writeFileSync(resolve(root, "worker-events.json"), `${JSON.stringify(value, null, 2)}\n`);
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP2-PROJ-003 project cleanup worker", () => {
it("queues all associated managed files without reducing capacity, then physically removes them", async () => {
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-03-worker-"));
roots.push(dataRoot);
mkdirSync(join(dataRoot, "db"), { recursive: true });
const databasePath = join(dataRoot, "db", "dada.sqlite3");
const storage = new ManagedStorage({ dataRoot, databasePath });
const projects = new ProjectService({ clock: () => now, databasePath });
const ownerId = randomUUID();
const project = projects.createProjectForGeneration({ ownerId, prompt: "清理项目", ratio: "3:4", status: "failed" });
const fileKinds = ["generated", "reference", "export"] as const;
const files = [];
for (const fileKind of fileKinds) {
const committed = await storage.commitStream({
content: Readable.from(png), expectedMimeType: "image/png", fileKind,
fileName: `${fileKind}.png`, operationId: randomUUID(), ownerRef: ownerId, projectedWriteBytes: png.byteLength,
});
files.push(committed);
projects.linkManagedResource(ownerId, project.project.projectId, committed.file_id, fileKind);
}
projects.saveProjectState({
expectedStateVersion: 1, idempotencyKey: "wp2-03-location-state-00000000000001", ownerId,
projectId: project.project.projectId,
state: {
canvas_state: {
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
elements: [{
coordinates: { latitude: 30.2741, longitude: 120.1551 }, created_at: new Date(now).toISOString(),
element_id: randomUUID(), opacity: 1, position: { x: 0, y: 0 }, resource_version: "v1", rotation: 0,
scale: { x: 1, y: 1 }, template_or_asset_id: "DYN004", type: "dynamic_sticker", z_index: 0,
}], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
},
name: "含定位信息的项目",
},
});
const bytesBeforePurge = storage.getState().managed_content_bytes;
projects.trashProject(ownerId, project.project.projectId);
projects.purgeProject(ownerId, project.project.projectId);
expect(storage.getState().managed_content_bytes).toBe(bytesBeforePurge);
expect(projects.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status = 'pending'").get()).toEqual({ count: 3 });
const worker = new ProjectPurgeCleanup({ clock: () => now, dataRoot, databasePath });
const cleanup = worker.run();
worker.close();
expect(cleanup).toEqual({
expired: 0,
physical: { completed: 3, failed: 0 },
relational: { completed: 1, failed: 0 },
});
expect(projects.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE project_id = ?").get(project.project.projectId)).toEqual({ count: 0 });
expect(projects.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(project.project.projectId)).toEqual({ count: 0 });
expect(storage.getState().managed_content_bytes).toBe(0);
expect(files.every((file) => storage.resolveManagedFile(file.file_id) === undefined)).toBe(true);
writeEvidence({ bytes_before_queue: bytesBeforePurge, bytes_while_queued: bytesBeforePurge, cleanup, resource_kinds: [...fileKinds, "location"] });
projects.close();
storage.close();
}, 20_000);
});