feat: complete TASK-WP2-04 credit ledger
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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 { createApp } from "../../apps/api/src/app.js";
|
||||
import { CreditService } from "../../apps/api/src/credits.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 baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
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`);
|
||||
}
|
||||
|
||||
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-04 credit APIs", () => {
|
||||
it("returns the user's balance and immutable paginated ledger", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-credit-api-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x51), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x52), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x53),
|
||||
});
|
||||
const credits = new CreditService({ clock: () => now, databasePath });
|
||||
closeables.push(credits, 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 (?, 'ledger@example.invalid', 'user', 'active', 1, ?, ?)`).run(userId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Ledger User', '@ledger')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||
registration.database.prepare(`INSERT INTO credit_ledger (
|
||||
ledger_id, user_id, operation_key, entry_type, amount,
|
||||
available_before, available_after, reserved_before, reserved_after, created_at
|
||||
) VALUES (?, ?, ?, 'registration_grant', 10, 0, 10, 0, 0, ?)`).run(randomUUID(), userId, `registration:${randomUUID()}`, now);
|
||||
const session = registration.issueAuthenticatedSession(userId, "user");
|
||||
const app = await createApp({ browserGate: false, credits, networkBoundary: { allowTestPort: true }, registration });
|
||||
const cookie = `dada_session=${session.sessionToken}`;
|
||||
const balance = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/me/credits" });
|
||||
const ledger = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/me/credit-ledger?limit=20" });
|
||||
expect(balance.statusCode).toBe(200);
|
||||
expect(balance.json()).toMatchObject({ available_balance: 10, reserved_balance: 0 });
|
||||
expect(ledger.statusCode).toBe(200);
|
||||
expect(ledger.json()).toMatchObject({ credits: { available_balance: 10, reserved_balance: 0 }, next_cursor: null });
|
||||
expect(ledger.json().entries).toHaveLength(1);
|
||||
expect(ledger.json().entries[0]).toMatchObject({ amount: 10, entry_type: "registration_grant", status: "succeeded" });
|
||||
evidence("TDD-WP2-CRED-001-reserve", "response.json", { balance: balance.json(), ledger: ledger.json() });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows an active super admin to adjust available only and replay safely", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp2-04-admin-api-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x61), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x62), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x63),
|
||||
});
|
||||
const credits = new CreditService({ clock: () => now, databasePath });
|
||||
closeables.push(credits, registration);
|
||||
const userId = randomUUID();
|
||||
const adminId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'adjusted@example.invalid', 'user', 'active', 1, ?, ?)`).run(userId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Adjusted User', '@adjusted')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 0, 1, ?)").run(userId, now);
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'adjuster@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 adminSession = registration.issueAuthenticatedSession(adminId, "admin");
|
||||
const app = await createApp({ browserGate: false, credits, networkBoundary: { allowTestPort: true }, registration });
|
||||
const cookie = `dada_admin_session=${adminSession.sessionToken}`;
|
||||
const sessionResponse = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/admin-auth/session" });
|
||||
const csrf = sessionResponse.json().csrf_token as string;
|
||||
const adjustmentId = randomUUID();
|
||||
const request = {
|
||||
body: { adjustment_id: adjustmentId, amount: -5, reason: "人工测试额度校正" },
|
||||
headers: { ...baseHeaders, cookie, "idempotency-key": randomUUID().replaceAll("-", "") + randomUUID().replaceAll("-", ""), "x-csrf-token": csrf },
|
||||
method: "POST" as const,
|
||||
url: `/api/v1/admin/users/${userId}/credit-adjustments`,
|
||||
};
|
||||
const before = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: `/api/v1/admin/users/${userId}/credits` });
|
||||
const first = await app.inject(request);
|
||||
const replay = await app.inject(request);
|
||||
const conflict = await app.inject({
|
||||
...request,
|
||||
body: { adjustment_id: randomUUID(), amount: 1, reason: "同键不同请求" },
|
||||
});
|
||||
const invalid = await app.inject({ ...request, body: { adjustment_id: randomUUID(), amount: 1, reason: "" } });
|
||||
expect(before.json()).toMatchObject({ available_balance: 0, reserved_balance: 1 });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json()).toEqual({ adjustment_id: adjustmentId, available_balance: -5, reserved_balance: 1, status: "adjusted" });
|
||||
expect(replay.json()).toEqual(first.json());
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
expect(conflict.json()).toMatchObject({ error: { code: "IDEMPOTENCY_KEY_CONFLICT" } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
evidence("TDD-WP2-CRED-002-admin-adjustment", "response.json", { before: before.json(), first: first.json(), invalid_status: invalid.statusCode, replay: replay.json() });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000601";
|
||||
const adminId = "00000000-0000-4000-8000-000000000602";
|
||||
const session = {
|
||||
audience: "user", authenticated: true, credits: { available_balance: -5, reserved_balance: 1 },
|
||||
csrf_token: "csrf-credit-fixture-0000000000000000000000000000000000",
|
||||
expires_at: "2026-09-02T12:00:00.000Z",
|
||||
user: { creator_name: "Credit User", role: "user", social_id: "@credit", status: "active", user_id: userId },
|
||||
};
|
||||
|
||||
test("TDD-WP2-CRED-001-insufficient shows negative available, reserved and no purchase entry", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
await page.route("**/api/v1/me/credits", (route) => route.fulfill({
|
||||
body: JSON.stringify({ available_balance: -5, reserved_balance: 1, updated_at: "2026-08-02T12:00:00.000Z" }), contentType: "application/json", status: 200,
|
||||
}));
|
||||
await page.route("**/api/v1/me/credit-ledger**", (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
credits: { available_balance: -5, reserved_balance: 1 },
|
||||
entries: [{
|
||||
amount: -5, available_after: -5, available_before: 0, created_at: "2026-08-02T12:00:00.000Z",
|
||||
entry_id: "00000000-0000-4000-8000-000000000603", entry_type: "admin_adjustment",
|
||||
model_id: null, reason: "人工测试额度校正", reference_id: "00000000-0000-4000-8000-000000000604",
|
||||
reference_type: "admin_adjustment", reserved_after: 1, reserved_before: 1, status: "succeeded",
|
||||
}], next_cursor: null, updated_at: "2026-08-02T12:00:00.000Z",
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
await page.goto(`${webUrl}/app/credits`);
|
||||
await expect(page.getByRole("heading", { name: "点数明细" })).toBeVisible();
|
||||
await expect(page.getByText("-5", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("已冻结 1 点")).toBeVisible();
|
||||
await expect(page.getByText("可用点数不足时,请联系管理员调整点数。" )).toBeVisible();
|
||||
await expect(page.getByText(/购买|充值/)).toHaveCount(0);
|
||||
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
|
||||
if (root) {
|
||||
const directory = resolve(root, "TDD-WP2-CRED-001-insufficient", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "insufficient.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP2-CRED-002-admin-adjustment previews and submits available-only negative balance", async ({ page }) => {
|
||||
let adjusted = false;
|
||||
const adminSession = {
|
||||
acknowledged_private_content_notice_version: null,
|
||||
admin: { role: "super_admin", status: "active", user_id: adminId }, audience: "admin", authenticated: true,
|
||||
csrf_token: "csrf-admin-credit-fixture-0000000000000000000000000000000",
|
||||
current_private_content_notice_version: null, expires_at: "2026-09-02T12:00:00.000Z", notice_acknowledged: false,
|
||||
};
|
||||
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/admin/users/${userId}/credits`, (route) => route.fulfill({
|
||||
body: JSON.stringify({ available_balance: adjusted ? -5 : 0, reserved_balance: 1, updated_at: "2026-08-02T12:00:00.000Z" }), contentType: "application/json", status: 200,
|
||||
}));
|
||||
await page.route(`**/api/v1/admin/users/${userId}/credit-adjustments`, (route) => {
|
||||
adjusted = true;
|
||||
const body = route.request().postDataJSON() as { adjustment_id: string };
|
||||
return route.fulfill({ body: JSON.stringify({ adjustment_id: body.adjustment_id, available_balance: -5, reserved_balance: 1, status: "adjusted" }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.goto(`${webUrl}/admin/users?userId=${userId}`);
|
||||
await page.getByRole("button", { name: "调整点数" }).click();
|
||||
await page.getByRole("radio", { name: "扣减" }).check();
|
||||
await page.getByLabel("调整数量").fill("5");
|
||||
await page.getByLabel("调整原因").fill("人工测试额度校正");
|
||||
await expect(page.getByText("调整后可用点数 -5")).toBeVisible();
|
||||
await expect(page.getByText("冻结点数保持 1")).toBeVisible();
|
||||
const root = process.env.DADA_EVIDENCE_DIR_CREDITS;
|
||||
if (root) {
|
||||
const directory = resolve(root, "TDD-WP2-CRED-002-admin-adjustment", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "negative-balance.png") });
|
||||
}
|
||||
await page.getByRole("button", { name: "确认调整" }).click();
|
||||
await expect(page.getByText("点数调整已完成并记录审计")).toBeVisible();
|
||||
expect(adjusted).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user