credit-ledger.test.js 5.54 KB
// 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);
});

test("adjust can move a balance either direction and records who did it", () => {
  const { users, creditLedger, user } = setup();
  creditLedger.topup({ userId: user.id, amount: 10 });

  const up = creditLedger.adjust({ userId: user.id, delta: 5, actorUserId: "admin-1" });
  assert.equal(up.balanceAfter, 15);
  assert.equal(up.actorUserId, "admin-1");
  assert.equal(up.reason, "admin_adjust");

  const down = creditLedger.adjust({ userId: user.id, delta: -3, actorUserId: "admin-1" });
  assert.equal(down.balanceAfter, 12);
  assert.equal(users.get(user.id).creditBalance, 12);
});

test("adjust refuses to push a balance negative, same as spend", () => {
  const { users, creditLedger, user } = setup();
  assert.throws(
    () => creditLedger.adjust({ userId: user.id, delta: -1 }),
    (err) => err.code === "INSUFFICIENT_BALANCE"
  );
  assert.equal(users.get(user.id).creditBalance, 0);
});

// "并发发起两次扣积分请求" 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);
});