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 { describe, expect, it } from "vitest"; import { CreditService } from "../../apps/api/src/credits.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 { settleGenerationCredits } from "../../apps/worker/src/credit-settlement.js"; const now = Date.parse("2026-08-02T12:00:00.000Z"); function evidence(file: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_CREDITS; if (!root) return; const directory = resolve(root, "TDD-WP2-CRED-001-finalize-once"); mkdirSync(directory, { recursive: true }); writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); } describe("TDD-WP2-CRED-001-finalize-once", () => { it("commits or releases each reservation exactly once across callback replay and restart", () => { const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-settle-")); 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 }); let credits = new CreditService({ clock: () => now, databasePath }); const rows = []; for (const outcome of ["succeeded", "failed", "rejected"] as const) { 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 (?, 'Settle User', '@settle')").run(userId); registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 1, 0, ?)").run(userId, now); const generationId = projects.createProjectForGeneration({ ownerId: userId, prompt: outcome, ratio: "3:4", status: "queued" }).generation.generationId; credits.reserveGeneration({ creditCost: 1, generationId, modelId: "gemini-3.1-flash-image-preview", operationKey: `generation:${generationId}:reserve`, userId }); const input = { generationId, operationKey: `generation:${generationId}:finalize`, outcome }; const first = settleGenerationCredits(credits, input); const callbackReplay = settleGenerationCredits(credits, input); credits.close(); credits = new CreditService({ clock: () => now, databasePath }); const restartReplay = settleGenerationCredits(credits, input); expect(callbackReplay).toEqual(first); expect(restartReplay).toEqual(first); const account = credits.readAccount(userId); expect(account).toMatchObject({ availableBalance: outcome === "succeeded" ? 0 : 1, reservedBalance: 0 }); const ledger = registration.database.prepare(` SELECT entry_type, COUNT(*) AS count FROM credit_ledger WHERE user_id = ? AND entry_type IN ('generation_commit', 'generation_release') GROUP BY entry_type `).get(userId); expect(ledger).toEqual({ count: 1, entry_type: outcome === "succeeded" ? "generation_commit" : "generation_release" }); rows.push({ account, generation_id: generationId, ledger, outcome }); } evidence("worker-events.json", { replay_count: 2, settlements: rows }); evidence("db-diff.json", { invariant: "available_plus_reserved changes only on commit; release restores available", settlements: rows }); evidence("fs-before.json", { managed_files: 0, output_files: 0 }); evidence("fs-after.json", { managed_files: 0, output_files: 0 }); credits.close(); projects.close(); registration.close(); rmSync(root, { force: true, recursive: true }); }); });