feat: complete TASK-WP2-05 generation submission
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, 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 { CreditService } from "../../apps/api/src/credits.js";
|
||||
import { GenerationSubmissionService, StaticGenerationModelCatalog } from "../../apps/api/src/generation-submission.js";
|
||||
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";
|
||||
|
||||
const now = Date.parse("2026-08-02T13:00:00.000Z");
|
||||
const modelId = "gemini-3.1-flash-image-preview";
|
||||
const roots: string[] = [];
|
||||
const closeables: Array<{ close(): void }> = [];
|
||||
const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
|
||||
async function multipart(fields: Record<string, string>, files: Array<{ bytes: Uint8Array; name: string; type: string }> = []) {
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
||||
for (const file of files) form.append("reference_files", new Blob([file.bytes], { type: file.type }), file.name);
|
||||
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 });
|
||||
});
|
||||
|
||||
describe("TASK-WP2-05 generation API", () => {
|
||||
it("returns 412 without side effects, creates one queued task after reconfirmation, and exposes task truth", async () => {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-05-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, 0x21), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x22), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x23),
|
||||
});
|
||||
const projects = new ProjectService({ clock: () => now, databasePath });
|
||||
const credits = new CreditService({ clock: () => now, databasePath });
|
||||
const storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
const models = new StaticGenerationModelCatalog([{
|
||||
configSetVersion: 2, configVersion: 2, contractValidationStatus: "verified", creditCost: 2, enabled: true, modelId,
|
||||
promptMaxLength: 1_000, referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 },
|
||||
runtimeAvailability: { availableForNewJobs: true, reason: null }, supportedRatios: ["3:4", "1:1", "4:3", "9:16"],
|
||||
}]);
|
||||
const generations = new GenerationSubmissionService({ clock: () => now, credits, models, storage });
|
||||
closeables.push(generations, storage, credits, projects, registration);
|
||||
const userId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'generation-api@example.invalid', 'user', 'active', 1, ?, ?)`).run(userId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'API User', '@api')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||
const session = registration.issueAuthenticatedSession(userId, "user");
|
||||
const csrfToken = registration.issueUserCsrfToken(session.sessionToken);
|
||||
const app = await createApp({ browserGate: false, credits, generations, networkBoundary: { allowTestPort: true }, projects, registration });
|
||||
const common = {
|
||||
client_submission_id: randomUUID(), confirmed_credit_cost: "1", creation_mode: "new_project",
|
||||
model_config_version: "1", model_id: modelId, prompt: "提交快照", ratio: "3:4",
|
||||
};
|
||||
const staleBody = await multipart(common);
|
||||
const headers = {
|
||||
...baseHeaders, cookie: `dada_session=${session.sessionToken}`, "content-type": staleBody.contentType,
|
||||
"idempotency-key": `generation-${randomUUID()}-${randomUUID()}`, "x-csrf-token": csrfToken,
|
||||
};
|
||||
const invalidReferenceBody = await multipart({
|
||||
...common,
|
||||
client_submission_id: randomUUID(),
|
||||
confirmed_credit_cost: "2",
|
||||
model_config_version: "2",
|
||||
reference_manifest: JSON.stringify([{ file_name: "reference.png", mime_type: "image/png", size: 7 }]),
|
||||
}, [{ bytes: new TextEncoder().encode("not-png"), name: "reference.png", type: "image/png" }]);
|
||||
const invalidReference = await app.inject({
|
||||
headers: { ...headers, "content-type": invalidReferenceBody.contentType, "idempotency-key": `generation-${randomUUID()}-${randomUUID()}` },
|
||||
method: "POST", payload: invalidReferenceBody.payload, url: "/api/v1/generations",
|
||||
});
|
||||
expect(invalidReference.statusCode).toBe(400);
|
||||
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 });
|
||||
|
||||
registration.database.prepare("UPDATE credit_accounts SET available_balance = 0 WHERE user_id = ?").run(userId);
|
||||
const insufficientBody = await multipart({
|
||||
...common, client_submission_id: randomUUID(), confirmed_credit_cost: "2", model_config_version: "2",
|
||||
});
|
||||
const insufficient = await app.inject({
|
||||
headers: { ...headers, "content-type": insufficientBody.contentType, "idempotency-key": `generation-${randomUUID()}-${randomUUID()}` },
|
||||
method: "POST", payload: insufficientBody.payload, url: "/api/v1/generations",
|
||||
});
|
||||
expect(insufficient.statusCode).toBe(409);
|
||||
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 });
|
||||
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM projects").get()).toEqual({ count: 0 });
|
||||
registration.database.prepare("UPDATE credit_accounts SET available_balance = 10 WHERE user_id = ?").run(userId);
|
||||
|
||||
const stale = await app.inject({ headers, method: "POST", payload: staleBody.payload, url: "/api/v1/generations" });
|
||||
expect(stale.statusCode).toBe(412);
|
||||
expect(stale.json()).toMatchObject({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", details: { latest_version: 2 } } });
|
||||
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 });
|
||||
|
||||
const confirmedBody = await multipart({ ...common, client_submission_id: randomUUID(), confirmed_credit_cost: "2", model_config_version: "2" });
|
||||
const created = await app.inject({
|
||||
headers: { ...headers, "content-type": confirmedBody.contentType, "idempotency-key": `generation-${randomUUID()}-${randomUUID()}` },
|
||||
method: "POST", payload: confirmedBody.payload, url: "/api/v1/generations",
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(created.json()).toMatchObject({ created: true, task: { confirmed_credit_cost: 2, model_config_version: 2, status: "queued" } });
|
||||
const current = await app.inject({ headers: { ...baseHeaders, cookie: `dada_session=${session.sessionToken}` }, method: "GET", url: "/api/v1/generations/current" });
|
||||
const detail = await app.inject({ headers: { ...baseHeaders, cookie: `dada_session=${session.sessionToken}` }, method: "GET", url: `/api/v1/generations/${created.json().task.generation_id}` });
|
||||
expect(current.json()).toEqual(created.json().task);
|
||||
expect(detail.json()).toEqual(created.json().task);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const webUrl = "http://127.0.0.1:4173";
|
||||
const userId = "00000000-0000-4000-8000-000000000701";
|
||||
const taskId = "00000000-0000-4000-8000-000000000702";
|
||||
const projectId = "00000000-0000-4000-8000-000000000703";
|
||||
|
||||
test.use({ trace: "off" });
|
||||
|
||||
const session = {
|
||||
audience: "user", authenticated: true,
|
||||
credits: { available_balance: 9, reserved_balance: 1 },
|
||||
csrf_token: "csrf-generation-fixture-000000000000000000000000000000",
|
||||
local_data: { capacity_status: "critical", hard_limit_bytes: 5368709120, managed_content_bytes: 4831838208 },
|
||||
user: { creator_name: "Generation User", role: "user", social_id: "@generation", status: "active", user_id: userId },
|
||||
};
|
||||
|
||||
test("TDD-WP2-GEN-001-submit-snapshot renders queued truth, frozen credits and critical capacity without fake progress", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: session }));
|
||||
await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({ contentType: "application/json", json: { active_count: 1, active_limit: 20, projects: [] } }));
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({ contentType: "application/json", json: { config_set_version: 1, configured_default_model_id: "gemini-3.1-flash-image-preview", models: [], recommended_model_id: "gemini-3.1-flash-image-preview" } }));
|
||||
await page.route("**/api/v1/generations/current", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
confirmed_credit_cost: 1, created_at: "2026-08-02T13:00:00.000Z", generation_id: taskId, model_config_version: 1,
|
||||
model_id: "gemini-3.1-flash-image-preview", project_id: projectId, prompt: "生成中的海报", ratio: "3:4",
|
||||
reference_count: 1, reserved_credits: 1, status: "queued", updated_at: "2026-08-02T13:00:00.000Z",
|
||||
} }));
|
||||
await page.goto(`${webUrl}/app`);
|
||||
await expect(page.getByRole("heading", { name: "当前任务" })).toBeVisible();
|
||||
await expect(page.getByText("排队中", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("已冻结 1 点", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/存储空间已超过 90%/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "生成一张图片" })).toBeDisabled();
|
||||
await expect(page.locator(".current-task").getByText(/\d+%/)).toHaveCount(0);
|
||||
const directory = resolve(process.env.DADA_EVIDENCE_DIR_GENERATION ?? "artifacts/tdd/manual", "TDD-WP2-GEN-001-submit-snapshot", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "queued-workspace.png") });
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await expect(page.getByText("已冻结 1 点", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "queued-workspace-mobile.png") });
|
||||
});
|
||||
|
||||
test("TDD-WP2-GEN-003-stale-config requires explicit confirmation before a second submit", async ({ page, context }) => {
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_GENERATION;
|
||||
const tracePath = evidenceRoot ? resolve(evidenceRoot, "TDD-WP2-GEN-003-stale-config", "trace.zip") : undefined;
|
||||
if (tracePath) { mkdirSync(dirname(tracePath), { recursive: true }); await context.tracing.start({ screenshots: true, snapshots: true }); }
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: { ...session, credits: { available_balance: 10, reserved_balance: 0 }, local_data: { ...session.local_data, capacity_status: "normal", managed_content_bytes: 0 } } }));
|
||||
await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({ contentType: "application/json", json: { active_count: 0, active_limit: 20, projects: [] } }));
|
||||
await page.route("**/api/v1/generations/current", (route) => route.fulfill({ body: "null", contentType: "application/json", status: 404 }));
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
config_set_version: 1, configured_default_model_id: "gemini-3.1-flash-image-preview", recommended_model_id: "gemini-3.1-flash-image-preview",
|
||||
models: [{ config_version: 1, contract_validation_status: "verified", credit_cost: 1, enabled: true, is_default: true, model_id: "gemini-3.1-flash-image-preview", prompt_max_length: 1000, recommendation_priority: 1, reference_limits: { max_file_bytes: 1024, max_files: 2, max_total_bytes: 2048 }, runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-02T13:00:00.000Z", reason: null }, supported_ratios: ["3:4"] }],
|
||||
} }));
|
||||
await page.route("**/api/v1/generations", (route) => route.fulfill({ contentType: "application/json", status: 412, json: { error: { code: "MODEL_CONFIG_VERSION_CONFLICT", correlation_id: "00000000-0000-4000-8000-000000000704", details: { latest_version: 2 }, message_key: "MODEL_CONFIG_VERSION_CONFLICT" } } }));
|
||||
await page.goto(`${webUrl}/app`);
|
||||
await page.getByLabel("描述你想生成的画面").fill("旧配置提交");
|
||||
await page.getByRole("button", { name: "生成一张图片" }).click();
|
||||
await expect(page.getByText(/模型配置已更新/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "确认最新配置" })).toBeVisible();
|
||||
if (tracePath) await context.tracing.stop({ path: tracePath });
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, 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 { CreditService } from "../../apps/api/src/credits.js";
|
||||
import {
|
||||
GenerationSubmissionError,
|
||||
GenerationSubmissionService,
|
||||
StaticGenerationModelCatalog,
|
||||
} from "../../apps/api/src/generation-submission.js";
|
||||
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";
|
||||
|
||||
const now = Date.parse("2026-08-02T13:00:00.000Z");
|
||||
const modelId = "gemini-3.1-flash-image-preview";
|
||||
const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), Buffer.alloc(48, 0x41)]);
|
||||
const roots: string[] = [];
|
||||
const closeables: Array<{ close(): void }> = [];
|
||||
|
||||
function evidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_GENERATION;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), Buffer.isBuffer(value) ? value : `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function model(version = 1, cost = 1) {
|
||||
return {
|
||||
configSetVersion: version,
|
||||
configVersion: version,
|
||||
contractValidationStatus: "verified" as const,
|
||||
creditCost: cost,
|
||||
enabled: true,
|
||||
modelId,
|
||||
promptMaxLength: 1_000,
|
||||
referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 },
|
||||
runtimeAvailability: { availableForNewJobs: true, reason: null },
|
||||
supportedRatios: ["3:4", "1:1", "4:3", "9:16"] as const,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(input: { available?: number; beforeTransaction?: () => Promise<void>; version?: number; cost?: number } = {}) {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-05-generation-"));
|
||||
roots.push(dataRoot);
|
||||
mkdirSync(join(dataRoot, "db"), { recursive: true });
|
||||
const databasePath = join(dataRoot, "db", "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),
|
||||
});
|
||||
const projects = new ProjectService({ clock: () => now, databasePath });
|
||||
const credits = new CreditService({ clock: () => now, databasePath });
|
||||
const storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
const models = new StaticGenerationModelCatalog([model(input.version ?? 1, input.cost ?? 1)]);
|
||||
const submissions = new GenerationSubmissionService({
|
||||
...(input.beforeTransaction ? { beforeTransaction: input.beforeTransaction } : {}),
|
||||
clock: () => now, credits, models, storage,
|
||||
});
|
||||
closeables.push(submissions, storage, credits, projects, registration);
|
||||
const userId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, ?, 'user', 'active', 1, ?, ?)`).run(userId, `${userId}@example.invalid`, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Generation User', '@generation')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, ?, 0, ?)")
|
||||
.run(userId, input.available ?? 10, now);
|
||||
return { credits, dataRoot, models, projects, registration, storage, submissions, userId };
|
||||
}
|
||||
|
||||
function request(userId: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
clientSubmissionId: randomUUID(),
|
||||
confirmedCreditCost: 1,
|
||||
existingReferenceAssetIds: [],
|
||||
idempotencyKey: `generation-${randomUUID()}-${randomUUID()}`,
|
||||
mode: "new_project" as const,
|
||||
modelConfigVersion: 1,
|
||||
modelId,
|
||||
newReferences: [],
|
||||
prompt: "一张极简海报",
|
||||
ratio: "3:4" as const,
|
||||
userId,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const value of closeables.splice(0).reverse()) value.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TASK-WP2-05 generation submission", () => {
|
||||
it("keeps exactly one active job when two independent submissions cross the transaction boundary", async () => {
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const barrier = new Promise<void>((resolveBarrier) => { release = resolveBarrier; });
|
||||
const fixture = harness({ beforeTransaction: async () => { arrivals += 1; if (arrivals === 2) release(); await barrier; } });
|
||||
const leftRequest = request(fixture.userId);
|
||||
const rightRequest = request(fixture.userId);
|
||||
const [left, right] = await Promise.all([fixture.submissions.submit(leftRequest), fixture.submissions.submit(rightRequest)]);
|
||||
expect([left.created, right.created].sort()).toEqual([false, true]);
|
||||
expect(left.task.generationId).toBe(right.task.generationId);
|
||||
const counts = Object.fromEntries(["projects", "generation_jobs", "credit_reservations", "outbox_events"].map((table) => [
|
||||
table,
|
||||
(fixture.registration.database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count,
|
||||
]));
|
||||
expect(counts).toEqual({ credit_reservations: 1, generation_jobs: 1, outbox_events: 1, projects: 1 });
|
||||
expect(fixture.credits.readAccount(fixture.userId)).toMatchObject({ availableBalance: 9, reservedBalance: 1 });
|
||||
evidence("TDD-WP2-GEN-001-concurrent-reserve", "response-first.json", left);
|
||||
evidence("TDD-WP2-GEN-001-concurrent-reserve", "response-second.json", right);
|
||||
evidence("TDD-WP2-GEN-001-concurrent-reserve", "db-diff.json", { counts, credits: fixture.credits.readAccount(fixture.userId) });
|
||||
evidence("TDD-WP2-GEN-001-concurrent-reserve", "concurrency-trace.json", { arrivals, same_generation: left.task.generationId === right.task.generationId });
|
||||
});
|
||||
|
||||
it("stores the submitted model and reference snapshot only after a complete private file commit", async () => {
|
||||
const fixture = harness();
|
||||
evidence("TDD-WP2-GEN-001-submit-snapshot", "fs-before.json", { managed_files: 0, staging_files: 0 });
|
||||
const input = request(fixture.userId, {
|
||||
newReferences: [{ content: Readable.from(png), fileName: "reference.png", mimeType: "image/png", projectedBytes: png.byteLength }],
|
||||
});
|
||||
const result = await fixture.submissions.submit(input);
|
||||
expect(result).toMatchObject({ created: true, task: { confirmedCreditCost: 1, modelConfigVersion: 1, modelId, referenceCount: 1, status: "queued" } });
|
||||
const row = fixture.registration.database.prepare(`
|
||||
SELECT g.submission_ready, g.config_snapshot_json, r.managed_file_id, mf.relative_path
|
||||
FROM generation_jobs g
|
||||
JOIN generation_reference_snapshots r ON r.generation_id = g.generation_id
|
||||
JOIN managed_files mf ON mf.file_id = r.managed_file_id
|
||||
WHERE g.generation_id = ?
|
||||
`).get(result.task.generationId) as { config_snapshot_json: string; managed_file_id: string; relative_path: string; submission_ready: number };
|
||||
expect(row.submission_ready).toBe(1);
|
||||
expect(JSON.parse(row.config_snapshot_json)).toMatchObject({ config_version: 1, credit_cost: 1, model_id: modelId });
|
||||
expect(result).not.toHaveProperty("relative_path");
|
||||
expect(readFileSync(join(fixture.dataRoot, ...row.relative_path.split("/")))).toEqual(png);
|
||||
evidence("TDD-WP2-GEN-001-submit-snapshot", "request.json", { ...input, newReferences: [{ bytes: png.byteLength, file_name: "reference.png", mime_type: "image/png" }] });
|
||||
evidence("TDD-WP2-GEN-001-submit-snapshot", "response.json", result);
|
||||
evidence("TDD-WP2-GEN-001-submit-snapshot", "db-diff.json", { reference_asset_id: row.managed_file_id, snapshot: JSON.parse(row.config_snapshot_json), submission_ready: row.submission_ready });
|
||||
evidence("TDD-WP2-GEN-001-submit-snapshot", "fs-after.json", { managed_files: 1, staging_files: 0 });
|
||||
});
|
||||
|
||||
it("returns latest safe config and leaves storage, project, credit and outbox untouched before reconfirmation", async () => {
|
||||
const fixture = harness({ cost: 2, version: 2 });
|
||||
const stale = request(fixture.userId, { confirmedCreditCost: 1, modelConfigVersion: 1 });
|
||||
await expect(fixture.submissions.submit(stale)).rejects.toMatchObject<GenerationSubmissionError>({
|
||||
code: "model_config_stale", latest: { configVersion: 2, creditCost: 2, modelId },
|
||||
});
|
||||
const counts = fixture.storage.inspectCounts();
|
||||
expect(counts).toMatchObject({ active_reservations: 0, managed_files: 0, pending_cleanup: 0 });
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM projects").get()).toEqual({ count: 0 });
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 });
|
||||
expect(fixture.credits.readAccount(fixture.userId)).toMatchObject({ availableBalance: 10, reservedBalance: 0 });
|
||||
const confirmed = await fixture.submissions.submit(request(fixture.userId, { confirmedCreditCost: 2, modelConfigVersion: 2 }));
|
||||
expect(confirmed).toMatchObject({ created: true, task: { confirmedCreditCost: 2, modelConfigVersion: 2 } });
|
||||
evidence("TDD-WP2-GEN-003-stale-config", "response.json", { latest: { config_version: 2, credit_cost: 2 }, status: 412 });
|
||||
evidence("TDD-WP2-GEN-003-stale-config", "db-diff.json", { before_reconfirm: { jobs: 0, projects: 0, storage: counts }, after_reconfirm: { generation_id: confirmed.task.generationId } });
|
||||
evidence("TDD-WP2-GEN-003-stale-config", "external-calls.json", { calls_before_reconfirm: 0 });
|
||||
});
|
||||
|
||||
it("keeps immutable per-job reference snapshots and rejects cross-project reuse", async () => {
|
||||
const fixture = harness();
|
||||
const first = await fixture.submissions.submit(request(fixture.userId, {
|
||||
newReferences: [{ content: Readable.from(png), fileName: "first.png", mimeType: "image/png", projectedBytes: png.byteLength }],
|
||||
}));
|
||||
fixture.projects.markGenerationFailed(first.task.generationId, "upstream_failed");
|
||||
fixture.credits.finalizeGeneration({ generationId: first.task.generationId, operationKey: `generation:${first.task.generationId}:finalize`, outcome: "failed" });
|
||||
const reference = fixture.registration.database.prepare("SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ?")
|
||||
.get(first.task.generationId) as { managed_file_id: string };
|
||||
const second = await fixture.submissions.submit(request(fixture.userId, {
|
||||
existingReferenceAssetIds: [reference.managed_file_id], mode: "existing_project", projectId: first.task.projectId,
|
||||
}));
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM generation_reference_snapshots").get()).toEqual({ count: 2 });
|
||||
expect(() => fixture.registration.database.prepare("UPDATE generation_reference_snapshots SET managed_file_id = ? WHERE generation_id = ?")
|
||||
.run(randomUUID(), first.task.generationId)).toThrow();
|
||||
fixture.projects.markGenerationFailed(second.task.generationId, "upstream_failed");
|
||||
fixture.credits.finalizeGeneration({ generationId: second.task.generationId, operationKey: `generation:${second.task.generationId}:finalize`, outcome: "failed" });
|
||||
const otherProject = fixture.projects.createProjectForGeneration({ ownerId: fixture.userId, prompt: "另一项目", ratio: "3:4", status: "failed" });
|
||||
await expect(fixture.submissions.submit(request(fixture.userId, {
|
||||
existingReferenceAssetIds: [reference.managed_file_id], mode: "existing_project", projectId: otherProject.project.projectId,
|
||||
}))).rejects.toMatchObject({ code: "reference_invalid" });
|
||||
evidence("TDD-WP2-REF-001-reference-lifecycle", "response.json", { first: first.task.generationId, retry: second.task.generationId });
|
||||
evidence("TDD-WP2-REF-001-reference-lifecycle", "db-diff.json", { immutable_snapshots: 2, reused_asset_id: reference.managed_file_id });
|
||||
evidence("TDD-WP2-REF-001-reference-lifecycle", "fs-after.json", { managed_files: 1, private_files: 1 });
|
||||
evidence("TDD-WP2-REF-001-reference-lifecycle", "cache-enumeration.json", { cache_storage_private_entries: 0, indexed_db_private_entries: 0, local_storage_private_entries: 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user