// The /admin/credits read view + /admin/credits/grant action behind the // Users dashboard tab. Mounts the REAL admin router (sans the cookie // auth middleware, which lives in index.js) over an ephemeral HTTP // server so the handler's validation runs for real, not just the // underlying ledger primitives. import { test, describe, before, after } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import express from "express"; import { initCredits, setUserTier, addPurchasedCredits } from "../credits.js"; import { adminRouter } from "../routes/admin.js"; let baseUrl; let server; const getCredits = async () => { const r = await fetch(`${baseUrl}/admin/credits`, { cache: "no-store" }); return { status: r.status, body: await r.json() }; }; const grant = async (payload) => { const r = await fetch(`${baseUrl}/admin/credits/grant`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); return { status: r.status, body: await r.json().catch(() => ({})) }; }; const rowFor = (body, key) => body.rows.find((r) => r.credit_key === key); describe("/admin/credits + /admin/credits/grant", () => { before(async () => { await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-admincred-")) }); // Seed one row of each ledger-key shape so the type derivation and // the per-tier balance math are both exercised. Default quotas: // Core lifetime=10, Pro monthly=50. await setUserTier({ userId: "alice", tier: "pro" }); // user:alice await addPurchasedCredits({ creditKey: "inst:bob", amount: 5 }); // Core install await addPurchasedCredits({ creditKey: "lic:deadbeefdeadbeef", amount: 2 }); // license pool const app = express(); app.use("/admin", adminRouter({ dataDir: "/tmp" })); await new Promise((resolve) => { server = app.listen(0, resolve); }); baseUrl = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); }); test("GET /credits derives a type from the credit-key prefix", async () => { const { status, body } = await getCredits(); assert.equal(status, 200); assert.equal(rowFor(body, "user:alice").type, "cloud"); assert.equal(rowFor(body, "inst:bob").type, "install"); assert.equal(rowFor(body, "lic:deadbeefdeadbeef").type, "license"); }); test("GET /credits computes remaining/total (not raw counters)", async () => { const { body } = await getCredits(); const alice = rowFor(body, "user:alice"); // Pro, monthly 50, nothing spent assert.equal(alice.remaining, 50); assert.equal(alice.purchased, 0); assert.equal(alice.total, 50); const bob = rowFor(body, "inst:bob"); // Core lifetime 10 + 5 purchased assert.equal(bob.remaining, 10); assert.equal(bob.purchased, 5); assert.equal(bob.total, 15); }); test("POST /grant adds to the purchased bucket and returns the new balance", async () => { const { status, body } = await grant({ credit_key: "inst:bob", amount: 7 }); assert.equal(status, 200); assert.equal(body.ok, true); assert.equal(body.granted, 7); assert.equal(body.purchased_balance, 12); // 5 seeded + 7 granted // ...and the read view reflects it (purchased 12, total 10 + 12). const { body: after } = await getCredits(); const bob = rowFor(after, "inst:bob"); assert.equal(bob.purchased, 12); assert.equal(bob.total, 22); }); test("POST /grant rejects non-positive / non-integer amounts", async () => { for (const amount of [0, -5, 1.5, "x"]) { const { status } = await grant({ credit_key: "inst:bob", amount }); assert.equal(status, 400, `amount=${amount} should be 400`); } }); test("POST /grant rejects an absurdly large amount (fat-finger guard)", async () => { const { status } = await grant({ credit_key: "inst:bob", amount: 2_000_000 }); assert.equal(status, 400); }); test("POST /grant 400s on missing credit_key, 404s on an unknown one", async () => { assert.equal((await grant({ amount: 5 })).status, 400); const unknown = await grant({ credit_key: "inst:nobody", amount: 5 }); assert.equal(unknown.status, 404); // The unknown key must NOT have been created as a side effect. const { body } = await getCredits(); assert.equal(rowFor(body, "inst:nobody"), undefined); }); });