feat: complete TASK-WP2-01 project foundations

This commit is contained in:
suyx
2026-07-28 20:12:12 +08:00
parent 59e566885b
commit 4d38530361
19 changed files with 3696 additions and 12 deletions
@@ -7,6 +7,7 @@ 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 { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
@@ -15,6 +16,7 @@ const fixedNow = Date.parse("2026-07-28T10:00:00.000Z");
const roots: string[] = [];
const services: RegistrationService[] = [];
const storages: ManagedStorage[] = [];
const projectServices: ProjectService[] = [];
function writeEvidence(environmentName: string, file: string, value: unknown) {
const directory = process.env[environmentName];
@@ -53,10 +55,13 @@ async function createRegisteredHarness(email = "delete-me@example.invalid") {
registrationId: sent.registrationId,
socialId: "@delete_me",
});
return { completed, databasePath, email, resend, root, service, storage };
const projects = new ProjectService({ clock: () => fixedNow, databasePath });
projectServices.push(projects);
return { completed, databasePath, email, projects, resend, root, service, storage };
}
afterEach(() => {
for (const projects of projectServices.splice(0)) projects.close();
for (const service of services.splice(0)) service.close();
for (const storage of storages.splice(0)) storage.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
@@ -68,6 +73,7 @@ describe("TDD-WP1-DEL-001-account-delete", () => {
const userId = harness.completed.user.userId;
const secondSession = harness.service.issueAuthenticatedSession(userId, "user");
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
harness.projects.createProjectForGeneration({ ownerId: userId, prompt: "注销项目", ratio: "3:4", status: "failed" });
const files = await Promise.all([
harness.storage.commitStream({
content: Readable.from(Buffer.from("reference")), expectedMimeType: "application/octet-stream",
@@ -105,9 +111,10 @@ describe("TDD-WP1-DEL-001-account-delete", () => {
credits: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts WHERE user_id = ?").get(userId).count,
ledger: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE user_id = ?").get(userId).count,
profiles: harness.service.database.prepare("SELECT COUNT(*) AS count FROM user_profiles WHERE user_id = ?").get(userId).count,
projects: harness.service.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ?").get(userId).count,
sessions: harness.service.database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ?").get(userId).count,
};
expect(databaseState).toEqual({ account: 0, consents: 0, credits: 0, ledger: 0, profiles: 0, sessions: 0 });
expect(databaseState).toEqual({ account: 0, consents: 0, credits: 0, ledger: 0, profiles: 0, projects: 0, sessions: 0 });
expect(files.every((file) => harness.storage.resolveManagedFile(file.file_id) === undefined)).toBe(true);
expect(harness.storage.inspectCounts().pending_cleanup).toBe(3);
expect(harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE normalized_email = ?").get(harness.email).count).toBe(0);
+120
View File
@@ -0,0 +1,120 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, mkdirSync, 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-07-28T08:00:00.000Z");
const roots: string[] = [];
const services: ProjectService[] = [];
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-01-projects-"));
roots.push(root);
const service = new ProjectService({ clock: () => baseNow, databasePath: join(root, "dada.sqlite3") });
services.push(service);
return service;
}
function writeEvidence(caseId: string, file: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
if (!root) return;
const directory = resolve(root, caseId);
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, 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-001-new-versus-continue", () => {
it("creates only on explicit new work, fixes ratio, and isolates the history limit", () => {
const service = harness();
const ownerId = randomUUID();
for (let index = 0; index < 18; index += 1) {
service.createProjectForGeneration({ ownerId, prompt: `fixture ${index}`, ratio: "1:1", status: "failed" });
}
const first = service.createProjectForGeneration({
ownerId,
prompt: " 夜晚城市 中一间明亮的工作室,还有安静的雨声 ",
ratio: "3:4",
status: "failed",
});
const second = service.createProjectForGeneration({ ownerId, prompt: "第二张作品", ratio: "9:16", status: "failed" });
expect(first.project.projectId).not.toBe(second.project.projectId);
expect(first.project.name).toBe("夜晚城市 中一间明亮的工作室,还有安静的雨声 2026-07-28");
expect(service.listProjects(ownerId, "active")).toHaveLength(20);
expect(() => service.createProjectForGeneration({ ownerId, prompt: "第 21 个", ratio: "1:1", status: "failed" }))
.toThrowError(expect.objectContaining<ProjectError>({ code: "project_active_limit" }));
const beforeContinue = service.listProjects(ownerId, "active").length;
const continued = service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: "同项目继续", ratio: "3:4", status: "running",
});
expect(continued.projectId).toBe(first.project.projectId);
expect(service.listProjects(ownerId, "active")).toHaveLength(beforeContinue);
expect(() => service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: "不能改比例", ratio: "1:1", status: "failed",
})).toThrowError(expect.objectContaining<ProjectError>({ code: "project_ratio_fixed" }));
for (let index = 0; index < 10; index += 1) {
const generation = index === 0
? continued
: service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: `历史 ${index}`, ratio: "3:4", status: "running",
});
service.recordSuccessfulImage({ generationId: generation.generationId, imageId: randomUUID() });
}
expect(service.getProject(ownerId, first.project.projectId).successfulImageCount).toBe(10);
expect(() => service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: "第十一张", ratio: "3:4", status: "queued",
})).toThrowError(expect.objectContaining<ProjectError>({ code: "project_history_limit" }));
writeEvidence("TDD-WP2-PROJ-001-new-versus-continue", "db-diff.json", {
active_projects: 20,
first_project_images: 10,
project_delta_on_continue: 0,
});
writeEvidence("TDD-WP2-PROJ-001-new-versus-continue", "response.json", {
history_limit: "blocked",
new_project_ids: [first.project.projectId, second.project.projectId],
ratio_change: "blocked",
});
});
});
describe("TDD-WP2-PROJ-005-failed-draft-retry", () => {
it("keeps retries in one draft and trashes only failed-empty selections", () => {
const service = harness();
const ownerId = randomUUID();
const draft = service.createProjectForGeneration({ ownerId, prompt: "失败草稿", ratio: "4:3", status: "failed" });
const retryOne = service.retryFailedDraft({ ownerId, projectId: draft.project.projectId, prompt: "失败草稿 第一次重试" });
service.markGenerationFailed(retryOne.generationId, "upstream_timeout");
const retryTwo = service.retryFailedDraft({ ownerId, projectId: draft.project.projectId, prompt: "失败草稿 第二次重试" });
service.markGenerationFailed(retryTwo.generationId, "upstream_failed");
expect(service.getProject(ownerId, draft.project.projectId).generations).toHaveLength(3);
const otherDraft = service.createProjectForGeneration({ ownerId, prompt: "另一个失败草稿", ratio: "1:1", status: "failed" });
const successful = service.createProjectForGeneration({ ownerId, prompt: "已有成功图", ratio: "1:1", status: "running" });
service.recordSuccessfulImage({ generationId: successful.generation.generationId, imageId: randomUUID() });
const result = service.trashFailedEmpty(ownerId, [draft.project.projectId, otherDraft.project.projectId, successful.project.projectId]);
expect(result).toEqual({ ignoredProjectIds: [successful.project.projectId], trashedProjectIds: [draft.project.projectId, otherDraft.project.projectId] });
expect(service.getProject(ownerId, successful.project.projectId).status).toBe("active");
expect(service.getProject(ownerId, draft.project.projectId).status).toBe("trashed");
const renamed = service.renameProject(ownerId, successful.project.projectId, " 夏日海报 ");
expect(renamed.name).toBe("夏日海报");
writeEvidence("TDD-WP2-PROJ-005-failed-draft-retry", "db-diff.json", {
failed_empty_trashed: result.trashedProjectIds.length,
successful_project_status: "active",
retry_job_count: 3,
});
writeEvidence("TDD-WP2-PROJ-005-failed-draft-retry", "response.json", result);
});
});