121 lines
7.6 KiB
TypeScript
121 lines
7.6 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 { 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();
|
|
});
|
|
});
|