feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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-02T08: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[] = [];
|
||||
|
||||
const canvas = {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
|
||||
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
|
||||
function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp2-02-api-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "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),
|
||||
});
|
||||
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 (?, 'state@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'State User', '@state_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 };
|
||||
}
|
||||
|
||||
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE;
|
||||
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 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 });
|
||||
});
|
||||
|
||||
async function authenticatedApp() {
|
||||
const fixture = harness();
|
||||
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: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||
return { ...fixture, app, cookie, csrf: session.json().csrf_token as string };
|
||||
}
|
||||
|
||||
describe("TDD-WP2-PROJ-002-cas-success", () => {
|
||||
it("validates a complete Canvas snapshot and replays one idempotent CAS result without incrementing twice", async () => {
|
||||
const fixture = await authenticatedApp();
|
||||
const created = fixture.projectService.createProjectForGeneration({ ownerId: fixture.ownerId, prompt: "CAS 项目", ratio: "3:4", status: "failed" });
|
||||
const url = `/api/v1/projects/${created.project.projectId}/state`;
|
||||
const headers = { ...baseHeaders, cookie: fixture.cookie, "idempotency-key": "wp2-02-cas-key-00000000000000010", "if-match": "1", "x-csrf-token": fixture.csrf };
|
||||
const payload = { canvas_state: canvas, name: "CAS 保存成功" };
|
||||
|
||||
const first = await fixture.app.inject({ headers, method: "PUT", payload, url });
|
||||
const replay = await fixture.app.inject({ headers, method: "PUT", payload, url });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json()).toEqual({ save_status: "saved", state_version: 2 });
|
||||
expect(replay.statusCode).toBe(200);
|
||||
expect(replay.json()).toEqual(first.json());
|
||||
|
||||
const foreign = fixture.projectService.createProjectForGeneration({ ownerId: randomUUID(), prompt: "他人底图", ratio: "3:4", status: "running" });
|
||||
const foreignImageId = randomUUID();
|
||||
fixture.projectService.recordSuccessfulImage({ generationId: foreign.generation.generationId, imageId: foreignImageId });
|
||||
const foreignReference = await fixture.app.inject({
|
||||
headers: { ...headers, "idempotency-key": "wp2-02-foreign-000000000000000000", "if-match": "2" }, method: "PUT",
|
||||
payload: { canvas_state: { ...canvas, background: { ...canvas.background, asset_id: foreignImageId } }, name: "非法引用" }, url,
|
||||
});
|
||||
expect(foreignReference.statusCode).toBe(400);
|
||||
|
||||
const detail = await fixture.app.inject({ headers: { ...baseHeaders, cookie: fixture.cookie }, method: "GET", url: `/api/v1/projects/${created.project.projectId}` });
|
||||
expect(detail.json()).toMatchObject({ canvas_state: canvas, name: "CAS 保存成功", save_status: "saved", state_version: 2 });
|
||||
expect(fixture.projectService.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(created.project.projectId))
|
||||
.toEqual({ count: 2 });
|
||||
writeEvidence("TDD-WP2-PROJ-002-cas-success", "request.json", {
|
||||
expected_state_version: 1, payload, route: "/api/v1/projects/{projectId}/state",
|
||||
});
|
||||
writeEvidence("TDD-WP2-PROJ-002-cas-success", "response.json", { first: first.json(), replay: replay.json() });
|
||||
writeEvidence("TDD-WP2-PROJ-002-cas-success", "db-diff.json", {
|
||||
foreign_reference_write: 0, project_state_rows: 2, state_version_delta: 1,
|
||||
});
|
||||
await fixture.app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("TDD-WP2-PROJ-004-stale-tab", () => {
|
||||
it("returns 412 with only the latest safe version and leaves the stale tab without any write", async () => {
|
||||
const fixture = await authenticatedApp();
|
||||
const created = fixture.projectService.createProjectForGeneration({ ownerId: fixture.ownerId, prompt: "多标签", ratio: "3:4", status: "failed" });
|
||||
const url = `/api/v1/projects/${created.project.projectId}/state`;
|
||||
const request = (name: string, key: string) => fixture.app.inject({
|
||||
headers: { ...baseHeaders, cookie: fixture.cookie, "idempotency-key": key, "if-match": "1", "x-csrf-token": fixture.csrf },
|
||||
method: "PUT", payload: { canvas_state: canvas, name }, url,
|
||||
});
|
||||
const newer = await request("标签 A", "wp2-02-tab-a-0000000000000000010");
|
||||
const stale = await request("标签 B", "wp2-02-tab-b-0000000000000000010");
|
||||
expect(newer.statusCode).toBe(200);
|
||||
expect(stale.statusCode).toBe(412);
|
||||
expect(stale.json()).toEqual({ latest_state_version: 2, save_status: "conflicted" });
|
||||
expect(fixture.projectService.getProject(fixture.ownerId, created.project.projectId)).toMatchObject({ name: "标签 A", stateVersion: 2 });
|
||||
expect(fixture.projectService.listProjects(fixture.ownerId, "active")).toHaveLength(1);
|
||||
expect(fixture.projectService.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(created.project.projectId))
|
||||
.toEqual({ count: 2 });
|
||||
writeEvidence("TDD-WP2-PROJ-004-stale-tab", "response.json", { newer: newer.json(), stale: stale.json() });
|
||||
writeEvidence("TDD-WP2-PROJ-004-stale-tab", "db-diff.json", { project_count: 1, stale_write_delta: 0, state_rows: 2 });
|
||||
await fixture.app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
Reference in New Issue
Block a user