credit-ledger.test.js
5.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// 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);
});