// RED/GREEN check for credit_ledger (dev-plan-4.md T23; schema: // dressup-dance-room-mvcp.md M.2). Run: node --test credit-ledger.test.js const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { openDb, createUsersStore, createCreditLedgerStore } = require("./db.js"); function setup() { const db = openDb(path.join(fs.mkdtempSync(path.join(os.tmpdir(), "credit-ledger-test-")), "data.sqlite")); const users = createUsersStore(db); const creditLedger = createCreditLedgerStore(db, users); const user = users.create({ email: "spender@example.com", passwordHash: "hash1" }); return { db, users, creditLedger, user }; } test("topup credits a user and balanceAfter matches users.creditBalance", () => { const { users, creditLedger, user } = setup(); const row = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_1" }); assert.equal(row.delta, 100); assert.equal(row.balanceAfter, 100); assert.equal(users.get(user.id).creditBalance, 100); }); test("spend deducts and refuses to go negative", () => { const { users, creditLedger, user } = setup(); creditLedger.topup({ userId: user.id, amount: 20 }); const spent = creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment", relatedAssetId: "garment-1" }); assert.equal(spent.delta, -10); assert.equal(spent.balanceAfter, 10); assert.equal(users.get(user.id).creditBalance, 10); assert.throws( () => creditLedger.spend({ userId: user.id, amount: 999, reason: "spend_garment" }), (err) => err.code === "INSUFFICIENT_BALANCE" ); // a rejected spend must not have partially applied assert.equal(users.get(user.id).creditBalance, 10); }); test("refund credits back a specific spend and is traceable by relatedAssetId", () => { const { users, creditLedger, user } = setup(); creditLedger.topup({ userId: user.id, amount: 10 }); creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment", relatedAssetId: "garment-2" }); assert.equal(users.get(user.id).creditBalance, 0); const refunded = creditLedger.refund({ userId: user.id, amount: 10, reason: "refund_stage2_fail", relatedAssetId: "garment-2" }); assert.equal(refunded.delta, 10); assert.equal(refunded.balanceAfter, 10); assert.equal(users.get(user.id).creditBalance, 10); const refunds = creditLedger.findByRelatedAssetAndReason("garment-2", "refund_stage2_fail"); assert.equal(refunds.length, 1); }); test("a full ledger for a user is queryable and balanceAfter is continuously computable", () => { const { creditLedger, user } = setup(); creditLedger.topup({ userId: user.id, amount: 50 }); creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment" }); creditLedger.spend({ userId: user.id, amount: 5, reason: "spend_garment" }); const rows = creditLedger.listByUser(user.id); assert.equal(rows.length, 3); // newest first assert.equal(rows[0].balanceAfter, 35); assert.equal(rows[1].balanceAfter, 40); assert.equal(rows[2].balanceAfter, 50); }); test("topup with the same paymentRef twice only credits once (Stripe at-least-once webhook delivery)", () => { const { users, creditLedger, user } = setup(); const first = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_dup" }); const second = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_dup" }); assert.equal(first.id, second.id); assert.equal(users.get(user.id).creditBalance, 100); }); // "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's // DatabaseSync is synchronous, so two Promise.all-launched spends can't // actually interleave mid-transaction within one process; what this proves // is that back-to-back spends against a shared balance never lose an // update and never let the balance go negative. test("concurrent-looking spend calls against a shared balance never lose an update or go negative", async () => { const { users, creditLedger, user } = setup(); creditLedger.topup({ userId: user.id, amount: 15 }); const attempt = (amount) => Promise.resolve().then(() => creditLedger.spend({ userId: user.id, amount, reason: "spend_garment" })); const results = await Promise.allSettled([attempt(10), attempt(10)]); const fulfilled = results.filter((r) => r.status === "fulfilled"); const rejected = results.filter((r) => r.status === "rejected"); assert.equal(fulfilled.length, 1, "only one of the two 10-credit spends should succeed against a balance of 15"); assert.equal(rejected.length, 1); assert.equal(rejected[0].reason.code, "INSUFFICIENT_BALANCE"); assert.equal(users.get(user.id).creditBalance, 5); });