feat: complete TASK-WP2-07 latest exports
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { createHash, 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 { LatestExportService } from "../../apps/api/src/latest-exports.js";
|
||||
import { HARD_LIMIT_BYTES, 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";
|
||||
|
||||
const now = Date.parse("2026-08-03T08:00:00.000Z");
|
||||
const roots: string[] = [];
|
||||
const closeables: Array<{ close(): void }> = [];
|
||||
const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), Buffer.from("dada-export")]);
|
||||
const jpeg = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.from("dada-export-jpg")]);
|
||||
|
||||
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function multipart(fields: Record<string, string>, bytes: Buffer, type = "image/png") {
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
||||
form.append("export_file", new Blob([bytes], { type }), type === "image/png" ? "latest.png" : "latest.jpg");
|
||||
const serialized = new Response(form);
|
||||
return { contentType: serialized.headers.get("content-type")!, payload: Buffer.from(await serialized.arrayBuffer()) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const value of closeables.splice(0).reverse()) value.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-07-api-"));
|
||||
roots.push(dataRoot);
|
||||
mkdirSync(join(dataRoot, "db"), { recursive: true });
|
||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
|
||||
});
|
||||
const projects = new ProjectService({ clock: () => now, databasePath });
|
||||
const storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
const latestExports = new LatestExportService({ clock: () => now, databasePath, storage });
|
||||
closeables.push(latestExports, storage, projects, registration);
|
||||
const ownerId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'exports@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Export User', '@export')").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 csrf = registration.issueUserCsrfToken(session.sessionToken);
|
||||
const created = projects.createProjectForGeneration({ ownerId, prompt: "导出服务", ratio: "3:4", status: "queued" });
|
||||
const original = await storage.commitStream({
|
||||
content: (await import("node:stream")).Readable.from(png), expectedMimeType: "image/png", fileKind: "generated",
|
||||
fileName: "original.png", operationId: randomUUID(), ownerRef: ownerId, projectedWriteBytes: png.byteLength,
|
||||
});
|
||||
projects.linkManagedResource(ownerId, created.project.projectId, original.file_id, "generated");
|
||||
projects.recordSuccessfulImage({ generationId: created.generation.generationId, imageId: original.file_id });
|
||||
const app = await createApp({
|
||||
browserGate: false, latestExports, networkBoundary: { allowTestPort: true }, projects, registration,
|
||||
});
|
||||
const headers = { ...baseHeaders, cookie: `dada_session=${session.sessionToken}`, "x-csrf-token": csrf };
|
||||
return { app, created, headers, latestExports, ownerId, projects, registration, storage };
|
||||
}
|
||||
|
||||
describe("TDD-WP2-EXP-001 latest export API", () => {
|
||||
it("saves the uploaded bytes as the only latest export for that format and replays export_id without re-metering", async () => {
|
||||
const test = await fixture();
|
||||
const projectId = test.created.project.projectId;
|
||||
const stateVersion = test.projects.getProject(test.ownerId, projectId).stateVersion;
|
||||
const exportId = randomUUID();
|
||||
const sha256 = createHash("sha256").update(png).digest("hex");
|
||||
const body = await multipart({ byte_size: String(png.byteLength), export_id: exportId, format: "png", pixel_height: "1440", pixel_width: "1080", sha256, state_version: String(stateVersion) }, png);
|
||||
const request = () => test.app.inject({ headers: { ...test.headers, "content-type": body.contentType }, method: "PUT", payload: body.payload, url: `/api/v1/projects/${projectId}/latest-exports/png` });
|
||||
|
||||
const saved = await request();
|
||||
const metered = test.storage.getState().managed_content_bytes;
|
||||
const replay = await request();
|
||||
expect(saved.statusCode).toBe(200);
|
||||
expect(saved.json()).toMatchObject({ export_id: exportId, format: "png", sha256, state_version: stateVersion });
|
||||
expect(replay.json()).toEqual(saved.json());
|
||||
expect(test.storage.getState().managed_content_bytes).toBe(metered);
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ count: 1 });
|
||||
|
||||
const downloaded = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}/latest-exports/png` });
|
||||
expect(downloaded.statusCode).toBe(200);
|
||||
expect(downloaded.headers["cache-control"]).toContain("no-store");
|
||||
expect(downloaded.rawPayload).toEqual(png);
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "download.json", { byte_size: downloaded.rawPayload.byteLength, sha256: createHash("sha256").update(downloaded.rawPayload).digest("hex") });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "request.json", { byte_size: png.byteLength, export_id: exportId, format: "png", sha256, state_version: stateVersion });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "response.json", saved.json());
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "db-diff.json", { latest_rows: 1, replay_metered_delta: 0 });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "fs-before.json", { managed_export_files: 0 });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "fs-after.json", { managed_export_files: 1, sha256 });
|
||||
await test.app.close();
|
||||
});
|
||||
|
||||
it("replaces only the selected format and preserves old latest across conflict, persistence, full, and unavailable failures", async () => {
|
||||
const test = await fixture();
|
||||
const projectId = test.created.project.projectId;
|
||||
const stateVersion = test.projects.getProject(test.ownerId, projectId).stateVersion;
|
||||
const save = async (format: "jpg" | "png", bytes: Buffer, overrides: Partial<Record<"export_id" | "sha256" | "state_version", string>> = {}) => {
|
||||
const fields = {
|
||||
byte_size: String(bytes.byteLength),
|
||||
export_id: overrides.export_id ?? randomUUID(),
|
||||
format,
|
||||
pixel_height: "1440",
|
||||
pixel_width: "1080",
|
||||
sha256: overrides.sha256 ?? createHash("sha256").update(bytes).digest("hex"),
|
||||
state_version: overrides.state_version ?? String(stateVersion),
|
||||
};
|
||||
const body = await multipart(fields, bytes, format === "png" ? "image/png" : "image/jpeg");
|
||||
return test.app.inject({
|
||||
headers: { ...test.headers, "content-type": body.contentType }, method: "PUT", payload: body.payload,
|
||||
url: `/api/v1/projects/${projectId}/latest-exports/${format}`,
|
||||
});
|
||||
};
|
||||
|
||||
const oldPng = await save("png", png);
|
||||
const jpg = await save("jpg", jpeg);
|
||||
const replacementBytes = Buffer.concat([png, Buffer.from("-replacement")]);
|
||||
const replacement = await save("png", replacementBytes);
|
||||
expect([oldPng.statusCode, jpg.statusCode, replacement.statusCode]).toEqual([200, 200, 200]);
|
||||
const latestRows = test.registration.database.prepare("SELECT format, export_id FROM latest_exports WHERE project_id = ? ORDER BY format").all(projectId) as Array<{ export_id: string; format: string }>;
|
||||
expect(latestRows).toEqual([
|
||||
{ export_id: jpg.json().export_id, format: "jpg" },
|
||||
{ export_id: replacement.json().export_id, format: "png" },
|
||||
]);
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM managed_files WHERE file_kind = 'export' AND status = 'purged'").get()).toEqual({ count: 1 });
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE reason = 'purge' AND status = 'pending'").get()).toEqual({ count: 1 });
|
||||
|
||||
const expectedLatestId = replacement.json().export_id as string;
|
||||
const beforeOldReplay = test.storage.getState().managed_content_bytes;
|
||||
const oldReplay = await save("png", png, { export_id: oldPng.json().export_id });
|
||||
expect(oldReplay.statusCode).toBe(200);
|
||||
expect(oldReplay.json()).toEqual(oldPng.json());
|
||||
expect(test.storage.getState().managed_content_bytes).toBe(beforeOldReplay);
|
||||
const stale = await save("png", png, { state_version: String(stateVersion + 1) });
|
||||
const badHash = await save("png", png, { sha256: "0".repeat(64) });
|
||||
expect(stale.statusCode).toBe(409);
|
||||
expect(badHash.statusCode).toBe(503);
|
||||
expect(test.registration.database.prepare("SELECT export_id FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ export_id: expectedLatestId });
|
||||
test.storage.applyControlledMeasurement(HARD_LIMIT_BYTES);
|
||||
const full = await save("png", png);
|
||||
expect(full.statusCode).toBe(507);
|
||||
expect(full.json()).toMatchObject({ error: { code: "STORAGE_CAPACITY_EXCEEDED", details: { capacity_status: "full" } } });
|
||||
test.storage.applyControlledMeasurement(0);
|
||||
test.storage.setAvailability({ dataRootWritable: false, diskSpaceAvailable: true, sqliteWritable: true });
|
||||
const unavailable = await save("png", png);
|
||||
expect(unavailable.statusCode).toBe(507);
|
||||
expect(unavailable.json()).toMatchObject({ error: { details: { capacity_status: "unavailable" } } });
|
||||
expect(test.registration.database.prepare("SELECT export_id FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ export_id: expectedLatestId });
|
||||
writeEvidence("TDD-WP2-EXP-001-result-matrix", "response.json", {
|
||||
content_hash_failure: badHash.statusCode,
|
||||
storage_full: full.statusCode,
|
||||
storage_unavailable: unavailable.statusCode,
|
||||
state_conflict: stale.statusCode,
|
||||
});
|
||||
writeEvidence("TDD-WP2-EXP-001-result-matrix", "db-diff.json", { latest_export_id_after_failures: expectedLatestId, old_latest_preserved: true });
|
||||
await test.app.close();
|
||||
}, 15_000);
|
||||
|
||||
it("returns stable empty latest state without creating a row and serves only owned originals", async () => {
|
||||
const test = await fixture();
|
||||
const projectId = test.created.project.projectId;
|
||||
const before = test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports").get();
|
||||
const missing = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}/latest-exports/jpg` });
|
||||
const detail = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}` });
|
||||
const original = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/private-assets/projects/${projectId}/images/${test.projects.getProject(test.ownerId, projectId).currentImageId}` });
|
||||
const intruderId = randomUUID();
|
||||
test.registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'exports-other@example.invalid', 'user', 'active', 1, ?, ?)`).run(intruderId, randomUUID(), now);
|
||||
test.registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Other User', '@other_export')").run(intruderId);
|
||||
test.registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(intruderId, now);
|
||||
const intruder = test.registration.issueAuthenticatedSession(intruderId, "user");
|
||||
const denied = await test.app.inject({
|
||||
headers: { ...baseHeaders, cookie: `dada_session=${intruder.sessionToken}` }, method: "GET",
|
||||
url: `/api/v1/private-assets/projects/${projectId}/images/${test.projects.getProject(test.ownerId, projectId).currentImageId}`,
|
||||
});
|
||||
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(detail.json().latest_exports).toEqual([]);
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports").get()).toEqual(before);
|
||||
expect(original.statusCode).toBe(200);
|
||||
expect(original.rawPayload).toEqual(png);
|
||||
expect(denied.statusCode).toBe(404);
|
||||
writeEvidence("TDD-WP2-EXP-002-empty-latest", "response.json", { detail_latest_exports: detail.json().latest_exports, intruder_status: denied.statusCode, jpg_status: missing.statusCode, original_status: original.statusCode });
|
||||
writeEvidence("TDD-WP2-EXP-002-empty-latest", "db-diff.json", { after: before, before, created_by_read: 0 });
|
||||
await test.app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
const projectId = "00000000-0000-4000-8000-000000000701";
|
||||
const imageId = "00000000-0000-4000-8000-000000000702";
|
||||
|
||||
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());
|
||||
|
||||
test("TDD-WP2-EXP-002 shows the no-export state and only its two allowed actions", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify({ csrf_token: "csrf-latest-export-fixture-000000000000000000000000000000", user: { creator_name: "Export User" } }), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: imageId }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1 },
|
||||
created_at: "2026-08-03T08:00:00.000Z", current_image_id: imageId, deleted_at: null, draft_prompt: "导出空状态",
|
||||
generations: [], images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000703", image_id: imageId }],
|
||||
latest_exports: [], name: "导出空状态", pixel_height: 1440, pixel_width: 1080, project_id: projectId, purge_at: null,
|
||||
ratio: "3:4", save_status: "saved", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z",
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
const region = page.getByRole("region", { name: "最新成品" });
|
||||
await expect(region.getByText("暂无导出成品", { exact: true })).toBeVisible();
|
||||
await expect(region.getByRole("link", { name: "进入编辑并导出" })).toHaveAttribute("href", `/app/projects/${projectId}/editor`);
|
||||
await expect(region.getByRole("link", { name: "下载原始生成图" })).toHaveAttribute("href", `/api/v1/private-assets/projects/${projectId}/images/${imageId}`);
|
||||
await expect(region.getByRole("link")).toHaveCount(2);
|
||||
expect(await region.getByRole("button").count()).toBe(0);
|
||||
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (evidenceRoot) {
|
||||
const directory = resolve(evidenceRoot, "TDD-WP2-EXP-002-empty-latest", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "no-export.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP2-EXP-001 exposes the frozen browser result matrix without conflating download and persistence", async ({ page }) => {
|
||||
await page.goto(webUrl);
|
||||
const matrix = await page.evaluate(async () => {
|
||||
const { exportResultCopy, runExportFlow } = await import("/src/export-flow.ts");
|
||||
const run = async (downloadFails: boolean, persistFails: boolean) => runExportFlow({
|
||||
compose: async () => new Blob(["browser-export"], { type: "image/png" }),
|
||||
download: async () => { if (downloadFails) throw new Error("download_failed"); },
|
||||
persist: async () => { if (persistFails) throw new Error("persist_failed"); },
|
||||
});
|
||||
const results = [await run(false, false), await run(false, true), await run(true, false)];
|
||||
return results.map((result) => ({ copy: exportResultCopy[result.status], status: result.status }));
|
||||
});
|
||||
expect(matrix).toEqual([
|
||||
{ copy: "已下载并保存为最新成品", status: "downloaded_and_saved" },
|
||||
{ copy: "文件已下载,但未保存为最新成品", status: "downloaded_not_saved" },
|
||||
{ copy: "浏览器下载失败,可重新下载同一次合成结果", status: "download_failed" },
|
||||
]);
|
||||
await page.evaluate((items) => {
|
||||
document.body.innerHTML = `<main style="font-family:system-ui;padding:32px"><h1>导出结果矩阵</h1>${items.map((item) => `<p data-status="${item.status}" style="padding:16px;border:1px solid #111">${item.copy}</p>`).join("")}</main>`;
|
||||
}, matrix);
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (evidenceRoot) {
|
||||
const directory = resolve(evidenceRoot, "TDD-WP2-EXP-001-result-matrix", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "result.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP2-EXP-001 lists only the newest saved artifact for each format", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify({ csrf_token: "csrf-latest-list-fixture-00000000000000000000000000000000", user: { creator_name: "Export User" } }), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: imageId }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1 },
|
||||
created_at: "2026-08-03T08:00:00.000Z", current_image_id: imageId, deleted_at: null, draft_prompt: "最新成品",
|
||||
generations: [], images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000703", image_id: imageId }],
|
||||
latest_exports: ["jpg", "png"].map((format, index) => ({
|
||||
byte_size: 100 + index, created_at: "2026-08-03T08:00:00.000Z",
|
||||
download_url: `/api/v1/projects/${projectId}/latest-exports/${format}`,
|
||||
export_id: `00000000-0000-4000-8000-00000000071${index}`, format, pixel_height: 1440, pixel_width: 1080,
|
||||
sha256: String(index).repeat(64), state_version: 2,
|
||||
})),
|
||||
name: "最新成品", pixel_height: 1440, pixel_width: 1080, project_id: projectId, purge_at: null,
|
||||
ratio: "3:4", save_status: "saved", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z",
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
const region = page.getByRole("region", { name: "最新成品" });
|
||||
await expect(region.getByRole("link", { name: "重新下载" })).toHaveCount(2);
|
||||
await expect(region.getByText("JPG", { exact: true })).toBeVisible();
|
||||
await expect(region.getByText("PNG", { exact: true })).toBeVisible();
|
||||
await expect(region.getByText("暂无导出成品", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { exportResultCopy, runExportFlow } from "../../apps/web/src/export-flow.js";
|
||||
|
||||
function writeEvidence(file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, "TDD-WP2-EXP-001-result-matrix");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
describe("TDD-WP2-EXP-001-result-matrix", () => {
|
||||
it("uses the exact same Blob for download and latest persistence", async () => {
|
||||
const blob = new Blob(["same-export"], { type: "image/png" });
|
||||
const download = vi.fn(async (_value: Blob) => undefined);
|
||||
const persist = vi.fn(async (_value: Blob) => undefined);
|
||||
const result = await runExportFlow({ compose: async () => blob, download, persist });
|
||||
|
||||
expect(result.status).toBe("downloaded_and_saved");
|
||||
expect(download).toHaveBeenCalledWith(blob);
|
||||
expect(persist).toHaveBeenCalledWith(blob);
|
||||
expect(download.mock.calls[0][0]).toBe(persist.mock.calls[0][0]);
|
||||
expect(exportResultCopy[result.status]).toBe("已下载并保存为最新成品");
|
||||
});
|
||||
|
||||
it("preserves the composed Blob for retry when browser download fails", async () => {
|
||||
const blob = new Blob(["retry-export"], { type: "image/jpeg" });
|
||||
const persist = vi.fn();
|
||||
const result = await runExportFlow({
|
||||
compose: async () => blob,
|
||||
download: async () => { throw new Error("download_failed"); },
|
||||
persist,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("download_failed");
|
||||
expect(result.blob).toBe(blob);
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
expect(exportResultCopy[result.status]).toBe("浏览器下载失败,可重新下载同一次合成结果");
|
||||
});
|
||||
|
||||
it("does not turn a successful local download into a false double success", async () => {
|
||||
const result = await runExportFlow({
|
||||
compose: async () => new Blob(["local-only"], { type: "image/png" }),
|
||||
download: async () => undefined,
|
||||
persist: async () => { throw new Error("latest_failed"); },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("downloaded_not_saved");
|
||||
expect(exportResultCopy[result.status]).toBe("文件已下载,但未保存为最新成品");
|
||||
});
|
||||
|
||||
it("does not download or persist after composition failure", async () => {
|
||||
const download = vi.fn();
|
||||
const persist = vi.fn();
|
||||
const result = await runExportFlow({
|
||||
compose: async () => { throw new Error("composition_failed"); },
|
||||
download,
|
||||
persist,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ blob: null, status: "composition_failed" });
|
||||
expect(download).not.toHaveBeenCalled();
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
expect(exportResultCopy[result.status]).toBe("合成失败,请返回编辑器检查");
|
||||
writeEvidence("network-timeline.json", {
|
||||
composition_failed: ["compose:failed", "download:not_started", "persist:not_started"],
|
||||
download_failed: ["compose:succeeded", "download:failed", "persist:not_started"],
|
||||
downloaded_and_saved: ["compose:succeeded", "download:succeeded", "persist:succeeded"],
|
||||
downloaded_not_saved: ["compose:succeeded", "download:succeeded", "persist:failed"],
|
||||
});
|
||||
writeEvidence("response.json", { copy: exportResultCopy, statuses: Object.keys(exportResultCopy) });
|
||||
writeEvidence("db-diff.json", { browser_persistent_blob_writes: 0, latest_updates_after_backend_failure: 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user