feat: complete TASK-WP2-04 credit ledger

This commit is contained in:
suyx
2026-08-02 23:27:10 +08:00
parent 9b4860cfc2
commit 4551d73e76
24 changed files with 3002 additions and 6 deletions
@@ -0,0 +1,71 @@
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 });
});
});