131 lines
9.0 KiB
TypeScript
131 lines
9.0 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 { CreditError, 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";
|
|
|
|
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
|
const roots: string[] = [];
|
|
const closeables: Array<{ close(): void }> = [];
|
|
|
|
function evidence(caseId: string, file: string, value: unknown) {
|
|
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
|
|
if (!root) return;
|
|
const directory = resolve(root, caseId);
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function harness(available: number) {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-credit-"));
|
|
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 projects = new ProjectService({ clock: () => now, databasePath });
|
|
const credits = new CreditService({ clock: () => now, databasePath });
|
|
closeables.push(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 (?, 'Credit User', '@credit_user')").run(userId);
|
|
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, ?, 0, ?)")
|
|
.run(userId, available, now);
|
|
return { credits, databasePath, projects, registration, root, 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("TDD-WP2-CRED-001", () => {
|
|
it("reserves available credit exactly once and appends an immutable ledger event", () => {
|
|
const { credits, projects, registration, userId } = harness(10);
|
|
const created = projects.createProjectForGeneration({ ownerId: userId, prompt: "点数冻结", ratio: "3:4", status: "queued" });
|
|
const generationId = created.generation.generationId;
|
|
const operationKey = `generation:${generationId}:reserve`;
|
|
const request = { creditCost: 1, generationId, modelId: "gemini-3.1-flash-image-preview", operationKey, userId };
|
|
const first = credits.reserveGeneration(request);
|
|
expect(() => credits.reserveGeneration({ ...request, modelId: "gemini-3-pro-image-preview" }))
|
|
.toThrowError(expect.objectContaining<CreditError>({ code: "credit_operation_conflict" }));
|
|
const replay = credits.reserveGeneration(request);
|
|
expect(first).toEqual({ availableBalance: 9, reservedBalance: 1, status: "reserved" });
|
|
expect(replay).toEqual(first);
|
|
expect(credits.readAccount(userId)).toMatchObject({ availableBalance: 9, reservedBalance: 1 });
|
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE entry_type = 'generation_reserve'").get()).toEqual({ count: 1 });
|
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE aggregate_id = ?").get(generationId)).toEqual({ count: 1 });
|
|
expect(registration.database.prepare("SELECT reserved_credits, confirmed_credit_cost, final_credit_state FROM generation_jobs WHERE generation_id = ?").get(generationId))
|
|
.toEqual({ confirmed_credit_cost: 1, final_credit_state: null, reserved_credits: 1 });
|
|
expect(() => registration.database.prepare("UPDATE credit_ledger SET amount = 99 WHERE operation_key = ?").run(operationKey)).toThrow();
|
|
evidence("TDD-WP2-CRED-001-reserve", "request.json", request);
|
|
evidence("TDD-WP2-CRED-001-reserve", "response.json", first);
|
|
evidence("TDD-WP2-CRED-001-reserve", "db-diff.json", { account: credits.readAccount(userId), ledger_rows: 1, outbox_rows: 1 });
|
|
});
|
|
|
|
it("rejects insufficient available credit with no job, ledger, outbox or balance side effect", () => {
|
|
const { credits, registration, userId } = harness(0);
|
|
const generationId = randomUUID();
|
|
expect(() => credits.reserveGeneration({
|
|
creditCost: 1, generationId, modelId: "gemini-3.1-flash-image-preview",
|
|
operationKey: `generation:${generationId}:reserve`, userId,
|
|
})).toThrowError(expect.objectContaining<CreditError>({ code: "credit_insufficient" }));
|
|
const counts = {
|
|
jobs: (registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get() as { count: number }).count,
|
|
ledger: (registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get() as { count: number }).count,
|
|
outbox: (registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events").get() as { count: number }).count,
|
|
};
|
|
expect(counts).toEqual({ jobs: 0, ledger: 0, outbox: 0 });
|
|
expect(credits.readAccount(userId)).toMatchObject({ availableBalance: 0, reservedBalance: 0 });
|
|
evidence("TDD-WP2-CRED-001-insufficient", "response.json", { available_balance: 0, required_credits: 1, status: "insufficient" });
|
|
evidence("TDD-WP2-CRED-001-insufficient", "db-diff.json", { account: credits.readAccount(userId), ...counts });
|
|
evidence("TDD-WP2-CRED-001-insufficient", "external-calls.json", { calls: 0, staging_files: 0, storage_reservations: 0 });
|
|
});
|
|
});
|
|
|
|
describe("TDD-WP2-CRED-002-admin-adjustment", () => {
|
|
it("changes only available, replays by adjustment id, and rolls balance, ledger and audit back together", () => {
|
|
const { credits, projects, registration, userId } = harness(1);
|
|
const generation = projects.createProjectForGeneration({ ownerId: userId, prompt: "活动任务", ratio: "1:1", status: "queued" }).generation;
|
|
credits.reserveGeneration({ creditCost: 1, generationId: generation.generationId, modelId: "gemini-3.1-flash-image-preview", operationKey: `generation:${generation.generationId}:reserve`, userId });
|
|
const adminId = randomUUID();
|
|
registration.database.prepare(`
|
|
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
|
VALUES (?, 'admin-credit@example.invalid', 'super_admin', 'active', 0, ?, ?)
|
|
`).run(adminId, randomUUID(), now);
|
|
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
|
|
const adjustmentId = randomUUID();
|
|
const idempotencyKey = `admin-credit-${randomUUID()}-${randomUUID()}`;
|
|
const first = credits.adjustAvailable({ adjustmentId, adminId, amount: -5, idempotencyKey, reason: "测试额度校正", userId });
|
|
const replay = credits.adjustAvailable({ adjustmentId, adminId, amount: -5, idempotencyKey, reason: "测试额度校正", userId });
|
|
expect(first).toEqual({ adjustmentId, availableBalance: -5, reservedBalance: 1, status: "adjusted" });
|
|
expect(replay).toEqual(first);
|
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE entry_type = 'admin_adjustment'").get()).toEqual({ count: 1 });
|
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'credit_adjustment'").get()).toEqual({ count: 1 });
|
|
expect(() => credits.adjustAvailable({ adjustmentId: randomUUID(), adminId, amount: 1, idempotencyKey: `admin-credit-${randomUUID()}-${randomUUID()}`, reason: " ", userId }))
|
|
.toThrowError(expect.objectContaining<CreditError>({ code: "credit_request_invalid" }));
|
|
|
|
registration.database.exec(`CREATE TRIGGER fail_credit_adjustment_audit BEFORE INSERT ON admin_operation_logs
|
|
WHEN NEW.operation_type = 'credit_adjustment' BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END;`);
|
|
expect(() => credits.adjustAvailable({ adjustmentId: randomUUID(), adminId, amount: 2, idempotencyKey: `admin-credit-${randomUUID()}-${randomUUID()}`, reason: "审计失败回滚", userId })).toThrow();
|
|
registration.database.exec("DROP TRIGGER fail_credit_adjustment_audit");
|
|
expect(credits.readAccount(userId)).toMatchObject({ availableBalance: -5, reservedBalance: 1 });
|
|
const stored = registration.database.prepare(`
|
|
SELECT amount, reason, available_before, available_after, reserved_before, reserved_after
|
|
FROM credit_ledger WHERE entry_type = 'admin_adjustment'
|
|
`).get();
|
|
evidence("TDD-WP2-CRED-002-admin-adjustment", "db-diff.json", { account: credits.readAccount(userId), audit_rows: 1, ledger: stored });
|
|
});
|
|
});
|