feat: complete TASK-WP2-01 project foundations
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } 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-07-28T08:00:00.000Z");
|
||||
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const roots: string[] = [];
|
||||
const registrations: RegistrationService[] = [];
|
||||
const projects: ProjectService[] = [];
|
||||
|
||||
function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp2-01-api-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x21), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x22), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x23),
|
||||
});
|
||||
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 (?, 'projects@example.invalid', 'user', 'active', 1, ?, ?)
|
||||
`).run(ownerId, randomUUID(), now);
|
||||
registration.database.prepare(`
|
||||
INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Project User', '@project_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");
|
||||
return { ownerId, projectService, registration, session };
|
||||
}
|
||||
|
||||
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("TASK-WP2-01 project API", () => {
|
||||
it("returns owner-scoped summaries/detail and applies rename plus failed-empty batch trash", async () => {
|
||||
const fixture = harness();
|
||||
const failed = fixture.projectService.createProjectForGeneration({
|
||||
ownerId: fixture.ownerId, prompt: "API 失败草稿", ratio: "3:4", status: "failed",
|
||||
});
|
||||
const succeeded = fixture.projectService.createProjectForGeneration({
|
||||
ownerId: fixture.ownerId, prompt: "API 成功项目", ratio: "1:1", status: "running",
|
||||
});
|
||||
fixture.projectService.recordSuccessfulImage({ generationId: succeeded.generation.generationId, imageId: randomUUID() });
|
||||
const app = await createApp({
|
||||
browserGate: false, networkBoundary: { allowTestPort: true }, projects: fixture.projectService, registration: fixture.registration,
|
||||
});
|
||||
const cookie = `dada_session=${fixture.session.sessionToken}`;
|
||||
const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||
const csrf = session.json().csrf_token;
|
||||
|
||||
const list = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/projects?status=active" });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json()).toMatchObject({ active_count: 2, active_limit: 20 });
|
||||
expect(list.json().projects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ project_id: failed.project.projectId, status: "failed_empty", successful_image_count: 0 }),
|
||||
expect.objectContaining({ project_id: succeeded.project.projectId, status: "active", successful_image_count: 1 }),
|
||||
]));
|
||||
expect(list.json().projects[0]).not.toHaveProperty("draft_prompt");
|
||||
expect(list.json().projects[0]).not.toHaveProperty("generations");
|
||||
|
||||
const detail = await app.inject({
|
||||
headers: { ...headers, cookie }, method: "GET", url: `/api/v1/projects/${succeeded.project.projectId}`,
|
||||
});
|
||||
expect(detail.statusCode).toBe(200);
|
||||
expect(detail.json()).toMatchObject({ project_id: succeeded.project.projectId, ratio: "1:1", successful_image_count: 1 });
|
||||
|
||||
const rename = await app.inject({
|
||||
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "PATCH", payload: { name: "API 新名称" },
|
||||
url: `/api/v1/projects/${succeeded.project.projectId}`,
|
||||
});
|
||||
expect(rename.statusCode).toBe(200);
|
||||
expect(rename.json()).toMatchObject({ name: "API 新名称", status: "renamed" });
|
||||
|
||||
const batch = await app.inject({
|
||||
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST",
|
||||
payload: { project_ids: [failed.project.projectId, succeeded.project.projectId] },
|
||||
url: "/api/v1/projects/failed-empty/trash",
|
||||
});
|
||||
expect(batch.statusCode).toBe(200);
|
||||
expect(batch.json()).toEqual({ ignored_project_ids: [succeeded.project.projectId], trashed_project_ids: [failed.project.projectId] });
|
||||
expect(fixture.projectService.getProject(fixture.ownerId, succeeded.project.projectId).status).toBe("active");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not reveal another owner's project", async () => {
|
||||
const fixture = harness();
|
||||
const hidden = fixture.projectService.createProjectForGeneration({
|
||||
ownerId: randomUUID(), prompt: "另一个用户", ratio: "4:3", status: "failed",
|
||||
});
|
||||
const app = await createApp({
|
||||
browserGate: false, networkBoundary: { allowTestPort: true }, projects: fixture.projectService, registration: fixture.registration,
|
||||
});
|
||||
const response = await app.inject({
|
||||
headers: { ...headers, cookie: `dada_session=${fixture.session.sessionToken}` }, method: "GET",
|
||||
url: `/api/v1/projects/${hidden.project.projectId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
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 session = {
|
||||
audience: "user", authenticated: true,
|
||||
credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-projects-fixture-000000000000000000000000000000000000000",
|
||||
expires_at: "2026-08-28T08:00:00.000Z",
|
||||
user: { creator_name: "Project User", role: "user", social_id: "@project_user", status: "active", user_id: "00000000-0000-4000-8000-000000000201" },
|
||||
};
|
||||
|
||||
function routeSession(page: Page) {
|
||||
return page.route("**/api/v1/auth/session", (route) => route.fulfill({
|
||||
body: JSON.stringify(session), contentType: "application/json", status: 200,
|
||||
}));
|
||||
}
|
||||
|
||||
async function captureEvidence(page: Page, caseId: string, name: string) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId, "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, name) });
|
||||
}
|
||||
|
||||
test("TDD-WP2-PROJ-001 renders the empty WbDYT workspace without inventing a model", async ({ page }) => {
|
||||
await routeSession(page);
|
||||
await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({
|
||||
body: JSON.stringify({ active_count: 0, active_limit: 20, projects: [] }), contentType: "application/json", status: 200,
|
||||
}));
|
||||
await page.goto(`${webUrl}/app`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "开始一张新作品" })).toBeVisible();
|
||||
await expect(page.getByText("NEW PROJECT", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("描述你想生成的画面")).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "3:4" })).toBeChecked();
|
||||
for (const ratio of ["1:1", "4:3", "9:16"]) await expect(page.getByRole("radio", { name: ratio })).toBeVisible();
|
||||
await expect(page.getByText("当前没有可用于新任务的模型")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "生成一张图片" })).toBeDisabled();
|
||||
await expect(page.getByText("没有进行中的任务")).toBeVisible();
|
||||
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
||||
await expect(page.getByText("浏览示例模板")).toHaveCount(0);
|
||||
await captureEvidence(page, "TDD-WP2-PROJ-001-new-versus-continue", "workspace-empty.png");
|
||||
});
|
||||
|
||||
test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async ({ page }) => {
|
||||
await page.setViewportSize({ height: 760, width: 375 });
|
||||
await routeSession(page);
|
||||
const failedId = "00000000-0000-4000-8000-000000000211";
|
||||
const successId = "00000000-0000-4000-8000-000000000212";
|
||||
await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
active_count: 2, active_limit: 20,
|
||||
projects: [
|
||||
{ current_image_id: null, name: "失败草稿", project_id: failedId, ratio: "3:4", state_version: 1, status: "failed_empty", successful_image_count: 0, updated_at: "2026-07-28T08:00:00.000Z" },
|
||||
{ current_image_id: "00000000-0000-4000-8000-000000000213", name: "城市工作室", project_id: successId, ratio: "3:4", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-07-28T08:02:00.000Z" },
|
||||
],
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
let batchPayload: unknown;
|
||||
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
||||
batchPayload = route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({ ignored_project_ids: [], trashed_project_ids: [failedId] }), contentType: "application/json", status: 200,
|
||||
});
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
||||
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
||||
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
||||
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
||||
await page.getByLabel("选择失败草稿:失败草稿").check();
|
||||
await page.getByRole("button", { name: "批量移入回收站" }).click();
|
||||
expect(batchPayload).toEqual({ project_ids: [failedId] });
|
||||
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
|
||||
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
|
||||
});
|
||||
|
||||
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
||||
await routeSession(page);
|
||||
const projectId = "00000000-0000-4000-8000-000000000221";
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
|
||||
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
||||
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
||||
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
||||
image_id: `00000000-0000-4000-8000-${String(323 + index).padStart(12, "0")}`,
|
||||
})),
|
||||
name: "城市工作室", project_id: projectId, ratio: "3:4", state_version: 4,
|
||||
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
||||
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
||||
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
||||
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
||||
await expect(page.getByRole("radio")).toHaveCount(0);
|
||||
await captureEvidence(page, "TDD-WP2-PROJ-001-new-versus-continue", "project-detail-limit.png");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user