feat: complete TASK-WP2-06 generation terminal states
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const webUrl = "http://127.0.0.1:4173";
|
||||
const categories = [
|
||||
["upstream_timeout", "使用原输入重试"],
|
||||
["upstream_failed", "稍后重试"],
|
||||
["safety_rejected", "修改提示词或参考图"],
|
||||
["model_disabled", "选择其他模型或等待"],
|
||||
["gateway_balance_insufficient", "选择未受影响模型或联系管理员"],
|
||||
["gateway_contract_invalid", "选择其他模型或联系管理员"],
|
||||
["reference_invalid", "更换或移除参考图"],
|
||||
["unknown_retryable", "稍后重试"],
|
||||
["unknown_non_retryable", "联系管理员"],
|
||||
] as const;
|
||||
|
||||
test.use({ trace: "off" });
|
||||
|
||||
test("TASK-WP2-06 renders the single frozen action for every generation category", async ({ page }) => {
|
||||
let activeCategory: typeof categories[number][0] = "upstream_timeout";
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-terminal-fixture-00000000000000000000000000000000",
|
||||
local_data: { capacity_status: "normal", hard_limit_bytes: 5_368_709_120, managed_content_bytes: 0 },
|
||||
user: { creator_name: "Terminal User", role: "user", social_id: "@terminal", status: "active", user_id: "00000000-0000-4000-8000-000000000801" },
|
||||
} }));
|
||||
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: null,
|
||||
} }));
|
||||
await page.route("**/api/v1/generations/current", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
confirmed_credit_cost: 1, created_at: "2026-08-02T14:00:00.000Z", error_category: activeCategory,
|
||||
generation_id: "00000000-0000-4000-8000-000000000802", model_config_version: 1,
|
||||
model_id: "gemini-3.1-flash-image-preview", project_id: "00000000-0000-4000-8000-000000000803",
|
||||
prompt: "失败任务", ratio: "3:4", reference_count: 0, reserved_credits: 0,
|
||||
status: activeCategory === "safety_rejected" ? "rejected" : "failed", updated_at: "2026-08-02T14:01:00.000Z",
|
||||
} }));
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_GENERATION_RUNTIME;
|
||||
for (const [category, action] of categories) {
|
||||
activeCategory = category;
|
||||
await page.goto(`${webUrl}/app`);
|
||||
await expect(page.getByRole("button", { name: action })).toBeVisible();
|
||||
await expect(page.locator(".current-task").getByRole("button")).toHaveCount(1);
|
||||
await expect(page.getByText(/UPSTREAM_SECRET|internal\.invalid|stack trace/i)).toHaveCount(0);
|
||||
if (evidenceRoot) {
|
||||
const suffix = category === "gateway_balance_insufficient"
|
||||
? "gateway-balance"
|
||||
: category === "gateway_contract_invalid"
|
||||
? "gateway-contract"
|
||||
: category.replaceAll("_", "-");
|
||||
const directory = resolve(evidenceRoot, `TDD-WP2-ERR-001-${suffix}`, "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, `${category}.png`) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("TASK-WP2-06 succeeded task exposes its result without an error action", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 9, reserved_balance: 0 },
|
||||
csrf_token: "csrf-success-fixture-00000000000000000000000000000000",
|
||||
local_data: { capacity_status: "normal", hard_limit_bytes: 5_368_709_120, managed_content_bytes: 104 },
|
||||
user: { creator_name: "Success User", role: "user", social_id: "@success", status: "active", user_id: "00000000-0000-4000-8000-000000000811" },
|
||||
} }));
|
||||
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: null } }));
|
||||
await page.route("**/api/v1/generations/current", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
confirmed_credit_cost: 1, created_at: "2026-08-02T14:00:00.000Z", error_category: null,
|
||||
generation_id: "00000000-0000-4000-8000-000000000812", model_config_version: 1,
|
||||
model_id: "gemini-3.1-flash-image-preview", project_id: "00000000-0000-4000-8000-000000000813",
|
||||
prompt: "成功任务", ratio: "3:4", reference_count: 0, reserved_credits: 0,
|
||||
status: "succeeded", updated_at: "2026-08-02T14:01:00.000Z",
|
||||
} }));
|
||||
await page.goto(`${webUrl}/app`);
|
||||
await expect(page.getByText("生成成功", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "进入编辑" })).toBeVisible();
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_GENERATION_RUNTIME;
|
||||
if (evidenceRoot) {
|
||||
const directory = resolve(evidenceRoot, "TDD-WP2-GEN-002-success", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "succeeded.png") });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
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 { 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";
|
||||
import { GatewayBalanceRuntime } from "../../apps/worker/src/gateway-balance-runtime.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const now = Date.parse("2026-08-02T14:00:00.000Z");
|
||||
const models = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
|
||||
const closeables: Array<{ close(): void }> = [];
|
||||
const balanceEvidence: unknown[] = [];
|
||||
|
||||
function evidence(file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_GENERATION_RUNTIME;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, "TDD-WP2-BAL-001-balance-impact");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
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-06 gateway balance scope", () => {
|
||||
it.each([
|
||||
["model", [models[0]]],
|
||||
["account", models],
|
||||
["unknown", models],
|
||||
] as const)("applies %s impact without rewriting model configuration", (impactScope, expected) => {
|
||||
const root = mkdtempSync(join(tmpdir(), `dada-wp2-06-balance-${impactScope}-`));
|
||||
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),
|
||||
});
|
||||
const database = registration.database;
|
||||
database.exec("CREATE TABLE model_config_integrity (model_id TEXT PRIMARY KEY, config_hash TEXT NOT NULL, enabled INTEGER NOT NULL, is_default INTEGER NOT NULL, recommendation_priority INTEGER NOT NULL)");
|
||||
const insertConfig = database.prepare("INSERT INTO model_config_integrity VALUES (?, ?, 1, ?, ?)");
|
||||
models.forEach((modelId, index) => insertConfig.run(modelId, `hash-${index + 1}`, index === 0 ? 1 : 0, index + 1));
|
||||
const beforeConfig = database.prepare("SELECT * FROM model_config_integrity ORDER BY recommendation_priority").all();
|
||||
registration.close();
|
||||
|
||||
const runtime = new GatewayBalanceRuntime({ clock: () => now, databasePath });
|
||||
runtime.seedModels(models.map((modelId) => ({ gatewayAccountRef: "gateway-account-primary", modelId })));
|
||||
const eventId = randomUUID();
|
||||
const first = runtime.recordInsufficient({
|
||||
eventId, gatewayAccountRef: "gateway-account-primary", impactScope, modelId: models[0], sourceCategory: "adapter_balance_signal",
|
||||
});
|
||||
const replay = runtime.recordInsufficient({
|
||||
eventId, gatewayAccountRef: "gateway-account-primary", impactScope, modelId: models[0], sourceCategory: "adapter_balance_signal",
|
||||
});
|
||||
expect(replay).toEqual(first);
|
||||
expect(first.runtimeUnavailableModelIds.toSorted()).toEqual([...expected].toSorted());
|
||||
expect(runtime.listRuntime().filter((item) => !item.availableForNewJobs).map((item) => item.modelId).toSorted()).toEqual([...expected].toSorted());
|
||||
expect(() => runtime.restoreWithoutConfirmedRecovery("gateway-account-primary", randomUUID())).toThrow("gateway_balance_recovery_unconfirmed");
|
||||
const afterConfig = runtime.database.prepare("SELECT * FROM model_config_integrity ORDER BY recommendation_priority").all();
|
||||
expect(afterConfig).toEqual(beforeConfig);
|
||||
expect(runtime.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'gateway_balance_detected'").get()).toEqual({ count: 1 });
|
||||
expect(() => runtime.database.prepare("UPDATE admin_operation_logs SET result = 'failed'").run()).toThrow("admin_operation_logs_immutable");
|
||||
expect(() => runtime.database.prepare("DELETE FROM admin_operation_logs").run()).toThrow("admin_operation_logs_immutable");
|
||||
balanceEvidence.push({ impact_scope: impactScope, response: first, runtime: runtime.listRuntime() });
|
||||
evidence("response.json", { scopes: balanceEvidence });
|
||||
evidence("db-diff.json", { config_unchanged: afterConfig, scopes: balanceEvidence });
|
||||
evidence("worker-events.json", { replay_deduplicated: true, scopes: balanceEvidence });
|
||||
evidence("external-calls.json", { automatic_recharge_calls: 0, recovery_calls: 0 });
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("blocks a disabled model before job creation or credit reservation", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp2-06-model-disabled-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x41), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x42), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x43),
|
||||
});
|
||||
const projects = new ProjectService({ clock: () => now, databasePath });
|
||||
const credits = new CreditService({ clock: () => now, databasePath });
|
||||
const storage = new ManagedStorage({ dataRoot: root, databasePath });
|
||||
const submissions = new GenerationSubmissionService({
|
||||
clock: () => now, credits, storage,
|
||||
models: new StaticGenerationModelCatalog([{
|
||||
configSetVersion: 1, configVersion: 1, contractValidationStatus: "verified", creditCost: 1,
|
||||
enabled: false, modelId: models[0], promptMaxLength: 1_000,
|
||||
referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 },
|
||||
runtimeAvailability: { availableForNewJobs: false, reason: "model_disabled" }, supportedRatios: ["3:4"],
|
||||
}]),
|
||||
});
|
||||
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 (?, 'Disabled User', '@disabled')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||
await expect(submissions.submit({
|
||||
clientSubmissionId: randomUUID(), confirmedCreditCost: 1, existingReferenceAssetIds: [],
|
||||
idempotencyKey: `generation-${randomUUID()}-${randomUUID()}`, mode: "new_project", modelConfigVersion: 1,
|
||||
modelId: models[0], newReferences: [], prompt: "禁用模型", ratio: "3:4", userId,
|
||||
})).rejects.toMatchObject({ code: "generation_blocked", errorCategory: "model_disabled" });
|
||||
const counts = Object.fromEntries(["projects", "generation_jobs", "credit_reservations", "outbox_events"].map((table) => [
|
||||
table, (registration.database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count,
|
||||
]));
|
||||
expect(counts).toEqual({ credit_reservations: 0, generation_jobs: 0, outbox_events: 0, projects: 0 });
|
||||
evidence("../TDD-WP2-ERR-001-model-disabled/response.json", { error_category: "model_disabled", status: "not_created", user_action: "choose_model_or_contact_admin" });
|
||||
evidence("../TDD-WP2-ERR-001-model-disabled/db-diff.json", counts);
|
||||
evidence("../TDD-WP2-ERR-001-model-disabled/external-calls.json", { adapter_calls: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generationErrorRegistry } from "../../apps/worker/src/generation-error-registry.js";
|
||||
|
||||
describe("TASK-WP2-06 fixed generation error registry", () => {
|
||||
it("contains exactly the nine frozen categories and no supplier-facing text", () => {
|
||||
expect(Object.keys(generationErrorRegistry).toSorted()).toEqual([
|
||||
"gateway_balance_insufficient", "gateway_contract_invalid", "model_disabled", "reference_invalid",
|
||||
"safety_rejected", "unknown_non_retryable", "unknown_retryable", "upstream_failed", "upstream_timeout",
|
||||
]);
|
||||
expect(generationErrorRegistry).toMatchObject({
|
||||
gateway_balance_insufficient: { creditBehavior: "release_if_reserved", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" },
|
||||
gateway_contract_invalid: { creditBehavior: "release_if_reserved", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" },
|
||||
model_disabled: { creditBehavior: "no_reserve", taskOutcome: "not_created", userAction: "choose_model_or_contact_admin" },
|
||||
reference_invalid: { creditBehavior: "release_if_reserved", taskOutcome: "failed_or_not_created", userAction: "edit_input" },
|
||||
safety_rejected: { creditBehavior: "release_if_reserved", taskOutcome: "rejected", userAction: "edit_input" },
|
||||
unknown_non_retryable: { creditBehavior: "release_if_reserved", retryPolicy: "none", taskOutcome: "failed", userAction: "contact_admin" },
|
||||
unknown_retryable: { creditBehavior: "release_if_reserved", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" },
|
||||
upstream_failed: { creditBehavior: "release_if_reserved", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" },
|
||||
upstream_timeout: { creditBehavior: "release_if_reserved", retryPolicy: "immediate", taskOutcome: "failed", userAction: "retry_same_input" },
|
||||
});
|
||||
expect(JSON.stringify(generationErrorRegistry)).not.toMatch(/supplier|stack|https?:\/\//i);
|
||||
expect(Object.isFrozen(generationErrorRegistry)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
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 { 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";
|
||||
import { MockGenerationAdapter } from "../../apps/worker/src/ai-adapter-contract.js";
|
||||
import { GenerationProcessor } from "../../apps/worker/src/generation-processor.js";
|
||||
|
||||
const now = Date.parse("2026-08-02T14: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(96, 0x42)]);
|
||||
const roots: string[] = [];
|
||||
const closeables: Array<{ close(): void }> = [];
|
||||
|
||||
function evidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_GENERATION_RUNTIME;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function harness(adapter: MockGenerationAdapter) {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-06-worker-"));
|
||||
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 credits = new CreditService({ clock: () => now, databasePath });
|
||||
const storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
const submissions = new GenerationSubmissionService({
|
||||
clock: () => now, credits,
|
||||
models: new StaticGenerationModelCatalog([{
|
||||
configSetVersion: 1, configVersion: 1, contractValidationStatus: "verified", creditCost: 1, enabled: true, modelId,
|
||||
promptMaxLength: 1_000, referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 },
|
||||
runtimeAvailability: { availableForNewJobs: true, reason: null }, supportedRatios: ["3:4"],
|
||||
}]), 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, private_content_notice_version, private_content_notice_acknowledged_at)
|
||||
VALUES (?, 'Worker User', '@worker', NULL, NULL)
|
||||
`).run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)")
|
||||
.run(userId, now);
|
||||
const submitted = await submissions.submit({
|
||||
clientSubmissionId: randomUUID(), confirmedCreditCost: 1, existingReferenceAssetIds: [],
|
||||
idempotencyKey: `generation-${randomUUID()}-${randomUUID()}`, mode: "new_project", modelConfigVersion: 1,
|
||||
modelId, newReferences: [], prompt: "Worker 终态", ratio: "3:4", userId,
|
||||
});
|
||||
const processor = new GenerationProcessor({ adapter, clock: () => now + 1_000, dataRoot, databasePath, workerId: "worker-fixture" });
|
||||
closeables.push(processor);
|
||||
return { adapter, credits, dataRoot, generationId: submitted.task.generationId, processor, projectId: submitted.task.projectId, registration, userId };
|
||||
}
|
||||
|
||||
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-06 generation terminal processing", () => {
|
||||
it("persists one safe output before succeeding and commits credits once", async () => {
|
||||
const fixture = await harness(new MockGenerationAdapter({ outputs: [{ bytes: png, mimeType: "image/png", pixelHeight: 1440, pixelWidth: 1080 }], status: "completed" }));
|
||||
const first = await fixture.processor.processNext();
|
||||
const replay = await fixture.processor.processGeneration(fixture.generationId);
|
||||
expect(first).toMatchObject({ generationId: fixture.generationId, status: "succeeded" });
|
||||
expect(replay).toEqual(first);
|
||||
expect(fixture.registration.database.prepare("SELECT status, error_category, final_credit_state FROM generation_jobs WHERE generation_id = ?").get(fixture.generationId))
|
||||
.toEqual({ error_category: null, final_credit_state: "committed", status: "succeeded" });
|
||||
expect(fixture.registration.database.prepare("SELECT attempt_no FROM generation_jobs WHERE generation_id = ?").get(fixture.generationId)).toEqual({ attempt_no: 1 });
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?").get(fixture.projectId)).toEqual({ count: 1 });
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM managed_files WHERE file_kind = 'generated'").get()).toEqual({ count: 1 });
|
||||
evidence("TDD-WP2-GEN-002-success", "worker-events.json", { adapter_calls: fixture.adapter.calls, first, replay });
|
||||
evidence("TDD-WP2-GEN-002-success", "db-diff.json", { credits: fixture.credits.readAccount(fixture.userId), generation: first, history_count: 1 });
|
||||
evidence("TDD-WP2-GEN-002-success", "fs-after.json", { generated_files: 1, staging_files: 0 });
|
||||
});
|
||||
|
||||
it("releases credits and requires reconciliation when persistence fails after upstream success", async () => {
|
||||
const adapter = new MockGenerationAdapter({ outputs: [{ bytes: png, mimeType: "image/png", pixelHeight: 1440, pixelWidth: 1080 }], status: "completed" });
|
||||
const fixture = await harness(adapter);
|
||||
fixture.registration.database.prepare("UPDATE local_backend_storage_state SET storage_status = 'unavailable' WHERE singleton = 1").run();
|
||||
const first = await fixture.processor.processNext();
|
||||
const replay = await fixture.processor.processGeneration(fixture.generationId);
|
||||
expect(first).toMatchObject({ category: "unknown_retryable", status: "failed" });
|
||||
expect(replay).toEqual(first);
|
||||
expect(adapter.calls).toHaveLength(1);
|
||||
expect(fixture.credits.readAccount(fixture.userId)).toMatchObject({ availableBalance: 10, reservedBalance: 0 });
|
||||
expect(fixture.registration.database.prepare(`
|
||||
SELECT final_credit_state, upstream_cost_reconciliation FROM generation_jobs WHERE generation_id = ?
|
||||
`).get(fixture.generationId)).toEqual({ final_credit_state: "released", upstream_cost_reconciliation: "pending_manual_review" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"upstream_timeout", "upstream_failed", "safety_rejected", "gateway_balance_insufficient",
|
||||
"gateway_contract_invalid", "reference_invalid", "unknown_retryable", "unknown_non_retryable",
|
||||
] as const)("settles %s once without output or raw supplier details", async (category) => {
|
||||
const adapter = new MockGenerationAdapter({
|
||||
category,
|
||||
...(category === "gateway_balance_insufficient" ? { balanceSignal: { gatewayAccountRef: "gateway-account-primary", impactScope: "model" as const } } : {}),
|
||||
sourceCategory: "sanitized_fixture", status: "failed", unsafeRaw: "UPSTREAM_SECRET https://internal.invalid stack trace",
|
||||
});
|
||||
const fixture = await harness(adapter);
|
||||
const first = await fixture.processor.processNext();
|
||||
const replay = await fixture.processor.processGeneration(fixture.generationId);
|
||||
const expectedStatus = category === "safety_rejected" ? "rejected" : "failed";
|
||||
expect(first).toMatchObject({ category, generationId: fixture.generationId, status: expectedStatus });
|
||||
expect(replay).toEqual(first);
|
||||
expect(fixture.credits.readAccount(fixture.userId)).toMatchObject({ availableBalance: 10, reservedBalance: 0 });
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM managed_files WHERE file_kind = 'generated'").get()).toEqual({ count: 0 });
|
||||
expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE reference_id = ? AND entry_type = 'generation_release'").get(fixture.generationId)).toEqual({ count: 1 });
|
||||
expect(JSON.stringify(first)).not.toMatch(/UPSTREAM_SECRET|internal\.invalid|stack trace/i);
|
||||
const suffix = category === "gateway_balance_insufficient"
|
||||
? "gateway-balance"
|
||||
: category === "gateway_contract_invalid"
|
||||
? "gateway-contract"
|
||||
: category.replaceAll("_", "-");
|
||||
const caseId = `TDD-WP2-ERR-001-${suffix}`;
|
||||
evidence(caseId, "response.json", first);
|
||||
evidence(caseId, "worker-events.json", { adapter_calls: adapter.calls, replay_deduplicated: true });
|
||||
evidence(caseId, "db-diff.json", { credits: fixture.credits.readAccount(fixture.userId), generated_files: 0 });
|
||||
if (["gateway_balance_insufficient", "gateway_contract_invalid"].includes(category)) {
|
||||
evidence(caseId, "external-calls.json", { leaked_supplier_fields: 0, total_calls: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user