72 lines
4.3 KiB
TypeScript
72 lines
4.3 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 { 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 });
|
|
});
|
|
});
|