141 lines
9.4 KiB
TypeScript
141 lines
9.4 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|