@ssheleg/sheleg-dev 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +318 -0
- package/README.md +115 -1
- package/SECURITY.md +199 -52
- package/bin/sheleg-dev.js +18 -0
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/hooks/hooks.json +29 -0
- package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
- package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +77 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +397 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +17 -1
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +82 -77
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
- package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +111 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +564 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +39 -2
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The assertions a correct Stripe webhook handler must pass, and the proof that each one
|
|
3
|
+
// can fail.
|
|
4
|
+
//
|
|
5
|
+
// node assert-money-invariants.mjs # run them against the reference handler
|
|
6
|
+
// node assert-money-invariants.mjs --self-test # break the handler one rule at a time
|
|
7
|
+
//
|
|
8
|
+
// `--self-test` is the half that makes this evidence rather than decoration. For every
|
|
9
|
+
// invariant it names the rule whose removal MUST turn that invariant red, deletes exactly
|
|
10
|
+
// that rule, and fails if the assertion still passes. A check nobody has watched failing
|
|
11
|
+
// is indistinguishable from a check that cannot fail.
|
|
12
|
+
//
|
|
13
|
+
// It also prints the whole invariant x mutant matrix, because two rules that both explain
|
|
14
|
+
// a passing fixture mean neither is tested. Where `breaks` holds one rule, that fixture
|
|
15
|
+
// isolates it. Where it holds more, the overlap is written down instead of assumed away
|
|
16
|
+
// (`README.md` -> *What masks what*).
|
|
17
|
+
//
|
|
18
|
+
// To point this at YOUR handler: replace `createStore`/`createHandler` below with your
|
|
19
|
+
// own, keep the same three entry points (`deliver(event)`, `reconcile(...)`, and a store
|
|
20
|
+
// you can read counts off), and delete the `--self-test` block only after you have your
|
|
21
|
+
// own way of watching each assertion fail.
|
|
22
|
+
//
|
|
23
|
+
// No network, no filesystem writes, no clock. The only reads are the JSON fixtures beside
|
|
24
|
+
// this file.
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import {
|
|
31
|
+
createStore, createHandler, subscriptionIdOf, RULES, CREDITS_PER_PERIOD,
|
|
32
|
+
} from './reference-handler.mjs';
|
|
33
|
+
|
|
34
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const fixture = (name) => JSON.parse(readFileSync(path.join(HERE, name), 'utf8'));
|
|
36
|
+
|
|
37
|
+
const CYCLE_JAN = 'invoice-paid-subscription-cycle.json';
|
|
38
|
+
const CYCLE_JAN_REDELIVERY = 'invoice-paid-subscription-cycle-redelivery.json';
|
|
39
|
+
const CYCLE_FEB = 'invoice-paid-subscription-cycle-next-period.json';
|
|
40
|
+
const PRORATION = 'invoice-paid-subscription-update-proration.json';
|
|
41
|
+
const REFUND_PARTIAL = 'charge-refunded-partial.json';
|
|
42
|
+
const REFUND_REMAINDER = 'charge-refunded-remainder.json';
|
|
43
|
+
const SESSION_UNPAID = 'checkout-session-completed-async-unpaid.json';
|
|
44
|
+
const SESSION_FAILED = 'checkout-session-async-payment-failed.json';
|
|
45
|
+
const SESSION_PAID = 'checkout-session-completed-paid.json';
|
|
46
|
+
|
|
47
|
+
/** A fresh store and handler per invariant: no assertion inherits another's state. */
|
|
48
|
+
function harness(without = []) {
|
|
49
|
+
const store = createStore();
|
|
50
|
+
const handler = createHandler(store, { without });
|
|
51
|
+
return { store, handler };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function seedOneOffPurchase(store) {
|
|
55
|
+
// The reader's own database row for a $90 credit pack, bought before the refunds.
|
|
56
|
+
store.purchases.set('pi_PLACEHOLDER_creditpack', { amount: 9000, refundedTotal: 0 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function periodOfCycle(event) {
|
|
60
|
+
const line = event.data.object.lines.data[0];
|
|
61
|
+
return { start: line.period.start, end: line.period.end };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------- invariants
|
|
65
|
+
//
|
|
66
|
+
// `breaks` is a MEASURED list, not an intention: `--self-test` fails if the set of
|
|
67
|
+
// mutants that turn an invariant red is anything other than this.
|
|
68
|
+
|
|
69
|
+
export const INVARIANTS = [
|
|
70
|
+
{
|
|
71
|
+
id: 'renewal-grants-exactly-once',
|
|
72
|
+
fixtures: [CYCLE_JAN],
|
|
73
|
+
states: 'a paid renewal becomes exactly one grant, and the mirror carries its period',
|
|
74
|
+
breaks: ['grant-on-renewal'],
|
|
75
|
+
async run({ store, handler, assert }) {
|
|
76
|
+
const event = fixture(CYCLE_JAN);
|
|
77
|
+
const response = await handler.deliver(event);
|
|
78
|
+
// `unmutated`: no rule in RULES makes this route answer anything but 200 with that
|
|
79
|
+
// body, so neither assertion has ever been watched failing. They pin the envelope a
|
|
80
|
+
// reader copies; they are not evidence, and the verdict line counts them apart.
|
|
81
|
+
assert.unmutated.equal(response.status, 200);
|
|
82
|
+
assert.unmutated.deepEqual(response.body, { received: true });
|
|
83
|
+
assert.equal(store.grants.length, 1, 'a paid renewal granted nothing');
|
|
84
|
+
assert.equal(store.credits.get('usr_PLACEHOLDER_alice'), CREDITS_PER_PERIOD);
|
|
85
|
+
// `|| {}` on purpose: with no mirror row at all these two assertions must FAIL
|
|
86
|
+
// rather than throw a TypeError on the line above them. A throw aborts the run, and
|
|
87
|
+
// an assertion the runner never reached is one no mutant has been watched breaking.
|
|
88
|
+
const mirror = store.subscriptions.get('sub_PLACEHOLDER_alice') || {};
|
|
89
|
+
assert.equal(mirror.periodStart, 1767225600);
|
|
90
|
+
assert.equal(mirror.periodEnd, 1769904000, 'the period is not the line period');
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'proration-invoice-grants-nothing',
|
|
95
|
+
fixtures: [CYCLE_JAN, PRORATION],
|
|
96
|
+
states: 'billing_reason subscription_update is a $40 proration, not a month of product',
|
|
97
|
+
breaks: ['billing-reason', 'grant-on-renewal'],
|
|
98
|
+
async run({ store, handler, assert }) {
|
|
99
|
+
await handler.deliver(fixture(CYCLE_JAN));
|
|
100
|
+
const response = await handler.deliver(fixture(PRORATION));
|
|
101
|
+
// unmutated: nothing in RULES makes the endpoint answer 500 for a proration.
|
|
102
|
+
assert.unmutated.equal(response.status, 200, 'a proration invoice must not fail the endpoint');
|
|
103
|
+
assert.equal(store.grants.length, 1,
|
|
104
|
+
'the proration granted a second period — a user who adds and removes a seat '
|
|
105
|
+
+ 'four times has been given four months of product');
|
|
106
|
+
assert.ok(
|
|
107
|
+
store.log.some((l) => l.decision === 'skipped: subscription_update'),
|
|
108
|
+
'nothing recorded WHY the proration was skipped',
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'sequential-redelivery-grants-once',
|
|
114
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
115
|
+
states: 'the same evt_ delivered twice grants once AND runs its side effects once',
|
|
116
|
+
breaks: ['claim', 'grant-on-renewal'],
|
|
117
|
+
async run({ store, handler, assert }) {
|
|
118
|
+
const first = await handler.deliver(fixture(CYCLE_JAN));
|
|
119
|
+
const second = await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
120
|
+
assert.unmutated.deepEqual(first.body, { received: true }); // the envelope, again
|
|
121
|
+
assert.deepEqual(second.body, { received: true, duplicate: true },
|
|
122
|
+
'the second delivery was not recognised as a duplicate');
|
|
123
|
+
assert.equal(store.grants.length, 1, 'the redelivery granted a second time');
|
|
124
|
+
assert.equal(store.notifications.length, 1, '"your renewal" was sent twice');
|
|
125
|
+
assert.equal(store.conversions.length, 1,
|
|
126
|
+
'the server-side conversion fired twice — the revenue is counted twice');
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'concurrent-redelivery-grants-once',
|
|
131
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
132
|
+
states: 'two deliveries in flight at once grant once — the claim is an INSERT, not a SELECT',
|
|
133
|
+
breaks: ['claim', 'grant-on-renewal'],
|
|
134
|
+
async run({ store, handler, assert }) {
|
|
135
|
+
// 40 ms apart in production; here, both before either has finished its first read.
|
|
136
|
+
// This is the fixture that separates the event claim from the grant marker: the
|
|
137
|
+
// marker reads before it writes, and a read is a round trip.
|
|
138
|
+
const [a, b] = await Promise.all([
|
|
139
|
+
handler.deliver(fixture(CYCLE_JAN)),
|
|
140
|
+
handler.deliver(fixture(CYCLE_JAN_REDELIVERY)),
|
|
141
|
+
]);
|
|
142
|
+
const bodies = [a.body, b.body];
|
|
143
|
+
assert.equal(store.grants.length, 1, 'both deliveries granted');
|
|
144
|
+
assert.equal(store.credits.get('usr_PLACEHOLDER_alice'), CREDITS_PER_PERIOD,
|
|
145
|
+
'the user was credited twice for one renewal');
|
|
146
|
+
assert.equal(store.conversions.length, 1, 'the conversion fired twice');
|
|
147
|
+
assert.equal(bodies.filter((x) => x.duplicate === true).length, 1,
|
|
148
|
+
'exactly one of the two deliveries must answer duplicate');
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'reconciliation-does-not-regrant',
|
|
153
|
+
fixtures: [CYCLE_JAN],
|
|
154
|
+
states: 'the nightly repair reuses the webhook marker — a nightly job is not a nightly gift',
|
|
155
|
+
breaks: ['grant-marker', 'grant-on-renewal'],
|
|
156
|
+
async run({ store, handler, assert }) {
|
|
157
|
+
const event = fixture(CYCLE_JAN);
|
|
158
|
+
await handler.deliver(event);
|
|
159
|
+
const subId = subscriptionIdOf(event.data.object);
|
|
160
|
+
const outcome = await handler.reconcile(subId, periodOfCycle(event), {
|
|
161
|
+
userId: 'usr_PLACEHOLDER_alice',
|
|
162
|
+
});
|
|
163
|
+
assert.equal(outcome.granted, false, 'reconciliation granted a period the webhook granted');
|
|
164
|
+
assert.equal(store.grants.length, 1);
|
|
165
|
+
assert.equal(store.credits.get('usr_PLACEHOLDER_alice'), CREDITS_PER_PERIOD);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'out-of-order-pair-does-not-rewind-state',
|
|
170
|
+
fixtures: [CYCLE_FEB, CYCLE_JAN],
|
|
171
|
+
states: 'February delivered before January leaves February in the mirror, and grants both',
|
|
172
|
+
breaks: ['ordering', 'grant-on-renewal'],
|
|
173
|
+
async run({ store, handler, assert }) {
|
|
174
|
+
await handler.deliver(fixture(CYCLE_FEB)); // arrives first
|
|
175
|
+
await handler.deliver(fixture(CYCLE_JAN)); // the earlier period, delivered late
|
|
176
|
+
assert.equal(store.grants.length, 2, 'both periods were paid, so both are owed');
|
|
177
|
+
const mirror = store.subscriptions.get('sub_PLACEHOLDER_alice') || {};
|
|
178
|
+
assert.equal(mirror.periodStart, 1769904000,
|
|
179
|
+
'the mirror was rewound to the older period — state derived from arrival order');
|
|
180
|
+
assert.equal(mirror.periodEnd, 1772323200,
|
|
181
|
+
'the renewal date now points at a period that already ended');
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'refund-total-is-cumulative',
|
|
186
|
+
fixtures: [REFUND_PARTIAL, REFUND_REMAINDER],
|
|
187
|
+
states: 'amount_refunded is the total so far: two partials claw back $90, not $130',
|
|
188
|
+
breaks: ['cumulative-refund'],
|
|
189
|
+
async run({ store, handler, assert }) {
|
|
190
|
+
seedOneOffPurchase(store);
|
|
191
|
+
await handler.deliver(fixture(REFUND_PARTIAL));
|
|
192
|
+
await handler.deliver(fixture(REFUND_REMAINDER));
|
|
193
|
+
const total = store.clawbacks.reduce((sum, c) => sum + c.amount, 0);
|
|
194
|
+
assert.equal(total, 9000,
|
|
195
|
+
`clawed back ${total} minor units against a 9000 charge — amount_refunded was read `
|
|
196
|
+
+ 'as an increment');
|
|
197
|
+
assert.deepEqual(store.clawbacks.map((c) => c.amount), [4000, 5000]);
|
|
198
|
+
assert.equal(store.purchases.get('pi_PLACEHOLDER_creditpack').refundedTotal, 9000);
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'sequential-redelivery-grants-once-by-count-alone',
|
|
203
|
+
fixtures: [CYCLE_JAN, CYCLE_JAN_REDELIVERY],
|
|
204
|
+
states: 'the same pair judged ONLY by the grant count — masked, and here to prove it',
|
|
205
|
+
// Deliberately weaker than `sequential-redelivery-grants-once`, and kept because the
|
|
206
|
+
// measurement is the finding: no SINGLE removed rule turns this red. Delete the event
|
|
207
|
+
// claim and the per-period marker still refuses the second grant; delete the marker
|
|
208
|
+
// and the claim still refuses the second delivery. A suite that judged a redelivery by
|
|
209
|
+
// its grant count alone would ship with no idempotency and stay green.
|
|
210
|
+
breaks: [['claim', 'grant-marker'], 'grant-on-renewal'],
|
|
211
|
+
async run({ store, handler, assert }) {
|
|
212
|
+
await handler.deliver(fixture(CYCLE_JAN));
|
|
213
|
+
await handler.deliver(fixture(CYCLE_JAN_REDELIVERY));
|
|
214
|
+
assert.equal(store.grants.length, 1, 'the redelivery granted a second time');
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'duplicate-refund-claws-back-once',
|
|
219
|
+
fixtures: [REFUND_PARTIAL],
|
|
220
|
+
states: 'the same charge.refunded twice claws back $40 once — also masked',
|
|
221
|
+
// The second masked pair, and the reason the claim's own discriminating fixture is the
|
|
222
|
+
// CONCURRENT delivery rather than any duplicate: the compare-and-swap's
|
|
223
|
+
// `increment <= 0` catches a sequential replay by itself, and the claim catches it
|
|
224
|
+
// without the swap. Neither is tested by this fixture alone.
|
|
225
|
+
breaks: [['claim', 'cumulative-refund']],
|
|
226
|
+
async run({ store, handler, assert }) {
|
|
227
|
+
seedOneOffPurchase(store);
|
|
228
|
+
await handler.deliver(fixture(REFUND_PARTIAL));
|
|
229
|
+
await handler.deliver(fixture(REFUND_PARTIAL));
|
|
230
|
+
const total = store.clawbacks.reduce((sum, c) => sum + c.amount, 0);
|
|
231
|
+
assert.equal(total, 4000, `clawed back ${total} for one $40 refund`);
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: 'unpaid-session-grants-nothing',
|
|
236
|
+
fixtures: [SESSION_UNPAID, SESSION_FAILED],
|
|
237
|
+
states: 'the redirect proves a browser: an unpaid async session grants and converts nothing',
|
|
238
|
+
breaks: ['async-gate'],
|
|
239
|
+
async run({ store, handler, assert }) {
|
|
240
|
+
const completed = await handler.deliver(fixture(SESSION_UNPAID));
|
|
241
|
+
// unmutated: the envelope. Removing `async-gate` grants the session and still answers 200.
|
|
242
|
+
assert.unmutated.equal(completed.status, 200, 'an unpaid session must still answer 200');
|
|
243
|
+
assert.equal(store.grants.length, 0,
|
|
244
|
+
'a session whose payment_status is unpaid was granted — the money has not moved');
|
|
245
|
+
// unmutated, and the reason is a gap worth seeing: this handler emits conversions on
|
|
246
|
+
// `invoice.paid` only, so no rule here can make a checkout session report one. The
|
|
247
|
+
// assertion states the rule for a reader whose handler DOES emit from a session.
|
|
248
|
+
assert.unmutated.equal(store.conversions.length, 0,
|
|
249
|
+
'a purchase conversion was reported for a charge that has not cleared');
|
|
250
|
+
await handler.deliver(fixture(SESSION_FAILED));
|
|
251
|
+
assert.equal(store.grants.length, 0, 'the failed async payment still granted');
|
|
252
|
+
assert.unmutated.equal(store.conversions.length, 0); // same gap as above
|
|
253
|
+
assert.equal(store.credits.get('usr_PLACEHOLDER_bob'), undefined);
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'paid-session-grants-once',
|
|
258
|
+
fixtures: [SESSION_PAID],
|
|
259
|
+
states: 'the positive control: a card session that DID clear grants exactly once',
|
|
260
|
+
// Without it, a handler that refuses every checkout session satisfies the assertion
|
|
261
|
+
// above and this pack would have taught the opposite defect.
|
|
262
|
+
breaks: ['grant-on-checkout'],
|
|
263
|
+
async run({ store, handler, assert }) {
|
|
264
|
+
const response = await handler.deliver(fixture(SESSION_PAID));
|
|
265
|
+
assert.unmutated.deepEqual(response.body, { received: true }); // the envelope
|
|
266
|
+
assert.equal(store.grants.length, 1, 'a cleared card session granted nothing');
|
|
267
|
+
assert.equal(store.credits.get('usr_PLACEHOLDER_carol'), CREDITS_PER_PERIOD);
|
|
268
|
+
assert.ok(store.subscriptions.has('sub_PLACEHOLDER_carol'));
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: 'conversion-id-survives-the-session',
|
|
273
|
+
fixtures: [CYCLE_JAN],
|
|
274
|
+
states: 'the conversion id comes from subscription metadata, so browser and server share it',
|
|
275
|
+
breaks: ['conversion-id-from-metadata', 'grant-on-renewal'],
|
|
276
|
+
async run({ store, handler, assert }) {
|
|
277
|
+
await handler.deliver(fixture(CYCLE_JAN));
|
|
278
|
+
assert.equal(store.conversions.length, 1);
|
|
279
|
+
const [conversion = {}] = store.conversions;
|
|
280
|
+
assert.equal(conversion.eventId, 'evtid_PLACEHOLDER_purchase_jan',
|
|
281
|
+
'the server generated its own id — nothing will ever deduplicate against the pixel');
|
|
282
|
+
assert.equal(conversion.eventName, 'Purchase', 'the event name is not byte-equal');
|
|
283
|
+
assert.equal(conversion.source, 'webhook');
|
|
284
|
+
assert.equal(conversion.value, 29);
|
|
285
|
+
assert.equal(conversion.currency, 'USD');
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
// ------------------------------------------------- one assertion, one measurement
|
|
291
|
+
//
|
|
292
|
+
// Why the assertions are not called straight through `node:assert`. Until 2026-08-20 the
|
|
293
|
+
// runner returned on the FIRST throw and the matrix compared one row per invariant, so an
|
|
294
|
+
// invariant was measured as a single unit: any assertion that was not the sole
|
|
295
|
+
// discriminator could be replaced by `assert.ok(true)` and nothing went red. Measured that
|
|
296
|
+
// day — three neutered assertions (the `clawbacks.map` deepEqual, the `mirror.periodEnd`
|
|
297
|
+
// equality, and the `response.body` deepEqual) left `--self-test` at exit 0 still printing
|
|
298
|
+
// *"12 assertions, each watched failing"*, and `npm test` at 0 with them.
|
|
299
|
+
//
|
|
300
|
+
// So the `assert` an invariant receives is a RECORDER: same API, but a failure is
|
|
301
|
+
// remembered instead of thrown and the assertions after it still run. Each call site is one
|
|
302
|
+
// assertion with its own identity — its line in this file — and `--self-test` asks the
|
|
303
|
+
// question per assertion rather than per invariant.
|
|
304
|
+
//
|
|
305
|
+
// Two kinds, and the difference is declared at the call site rather than in a table:
|
|
306
|
+
//
|
|
307
|
+
// assert.equal(...) a DISCRIMINATING assertion. At least one mutant must turn it
|
|
308
|
+
// red, or it is not evidence of anything.
|
|
309
|
+
// assert.unmutated.equal() an assertion NO mutant in this pack varies. It pins the
|
|
310
|
+
// envelope or a field the rules do not touch, so it has never
|
|
311
|
+
// been watched failing and the verdict line counts it apart
|
|
312
|
+
// rather than as evidence. Measured, not asserted: the self-test
|
|
313
|
+
// fails if a mutant does break one, because that is a
|
|
314
|
+
// discriminator hiding in the wrong list.
|
|
315
|
+
//
|
|
316
|
+
// A reader pointing this at their own handler keeps writing `assert.equal`; the recorder is
|
|
317
|
+
// API-compatible with `node:assert/strict` for the methods below.
|
|
318
|
+
|
|
319
|
+
const SELF = import.meta.url;
|
|
320
|
+
const METHODS = ['equal', 'notEqual', 'deepEqual', 'notDeepEqual', 'ok', 'match', 'doesNotMatch'];
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The line in THIS file that called the recorder.
|
|
324
|
+
*
|
|
325
|
+
* `captureStackTrace(holder, boundary)` drops `boundary` and everything above it, so the
|
|
326
|
+
* top frame is the assertion's own line — no frame counting, which is what makes the
|
|
327
|
+
* identity survive a refactor of the recorder itself.
|
|
328
|
+
*/
|
|
329
|
+
function siteOf(boundary) {
|
|
330
|
+
const holder = {};
|
|
331
|
+
Error.captureStackTrace(holder, boundary);
|
|
332
|
+
const frame = (holder.stack || '').split('\n').slice(1).find((f) => f.includes(SELF));
|
|
333
|
+
const m = frame && frame.match(/:(\d+):\d+\)?\s*$/);
|
|
334
|
+
return m ? Number(m[1]) : 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function recorder() {
|
|
338
|
+
const seen = new Map(); // line -> { site, unmutated, ok, why }
|
|
339
|
+
|
|
340
|
+
function record(site, unmutated, name, args) {
|
|
341
|
+
let ok = true;
|
|
342
|
+
let why = null;
|
|
343
|
+
try {
|
|
344
|
+
assert[name](...args);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
ok = false;
|
|
347
|
+
why = error.message.split('\n')[0];
|
|
348
|
+
}
|
|
349
|
+
const prev = seen.get(site);
|
|
350
|
+
if (prev) {
|
|
351
|
+
prev.ok = prev.ok && ok;
|
|
352
|
+
prev.why = prev.why || why;
|
|
353
|
+
} else {
|
|
354
|
+
seen.set(site, { site, unmutated, ok, why });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const bind = (unmutated) => {
|
|
359
|
+
const api = {};
|
|
360
|
+
for (const name of METHODS) {
|
|
361
|
+
const call = (...args) => record(siteOf(call), unmutated, name, args);
|
|
362
|
+
api[name] = call;
|
|
363
|
+
}
|
|
364
|
+
return api;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const api = bind(false);
|
|
368
|
+
api.unmutated = bind(true);
|
|
369
|
+
return { api, seen };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ------------------------------------------------------------------------- runners
|
|
373
|
+
|
|
374
|
+
async function runOne(invariant, without) {
|
|
375
|
+
const { api, seen } = recorder();
|
|
376
|
+
const { store, handler } = harness(without);
|
|
377
|
+
let threw = null;
|
|
378
|
+
try {
|
|
379
|
+
await invariant.run({ store, handler, assert: api });
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// A real error — a `TypeError` on a row the mutant never wrote, say. It aborts the
|
|
382
|
+
// remaining assertions, and it is still the invariant going red.
|
|
383
|
+
threw = error.message.split('\n')[0];
|
|
384
|
+
}
|
|
385
|
+
const sites = [...seen.values()];
|
|
386
|
+
return {
|
|
387
|
+
threw,
|
|
388
|
+
sites,
|
|
389
|
+
failed: sites.filter((s) => !s.ok),
|
|
390
|
+
broke: Boolean(threw) || sites.some((s) => !s.ok),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function runAll() {
|
|
395
|
+
let failed = 0;
|
|
396
|
+
let asserted = 0;
|
|
397
|
+
for (const invariant of INVARIANTS) {
|
|
398
|
+
const result = await runOne(invariant, []);
|
|
399
|
+
asserted += result.sites.length;
|
|
400
|
+
if (result.broke) {
|
|
401
|
+
failed += 1;
|
|
402
|
+
console.error(`FAIL ${invariant.id}`);
|
|
403
|
+
for (const s of result.failed) console.error(` :${s.site} ${s.why}`);
|
|
404
|
+
if (result.threw) console.error(` threw: ${result.threw}`);
|
|
405
|
+
} else {
|
|
406
|
+
console.log(`pass ${invariant.id} (${result.sites.length} assertions) — ${invariant.states}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (failed) {
|
|
410
|
+
console.error(`\n${failed} of ${INVARIANTS.length} money invariants failed`);
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
console.log(`\nOK: ${INVARIANTS.length} money invariants hold against the handler `
|
|
414
|
+
+ `(${asserted} assertions)`);
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The broadest mutant: a handler that grants nothing at all. It breaks every assertion
|
|
420
|
+
* about a grant, so it is excluded when asking which fixture ISOLATES a rule — otherwise
|
|
421
|
+
* it would appear beside every answer and hide the ones that matter.
|
|
422
|
+
*/
|
|
423
|
+
const BROAD = ['grant-on-renewal'];
|
|
424
|
+
|
|
425
|
+
const key = (mutant) => [...mutant].sort().join('+');
|
|
426
|
+
|
|
427
|
+
async function selfTest() {
|
|
428
|
+
const problems = [];
|
|
429
|
+
|
|
430
|
+
// Every single rule, plus each multi-rule mutant some invariant declares. A combination
|
|
431
|
+
// exists only where the measurement forced it: where two rules cover each other, no
|
|
432
|
+
// single removal turns the assertion red, and pretending otherwise is the failure mode
|
|
433
|
+
// this block was written to make visible.
|
|
434
|
+
const combos = [];
|
|
435
|
+
for (const invariant of INVARIANTS) {
|
|
436
|
+
for (const declared of invariant.breaks) {
|
|
437
|
+
if (!Array.isArray(declared)) continue;
|
|
438
|
+
if (!combos.some((c) => key(c) === key(declared))) combos.push(declared);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const mutants = [...RULES.map((rule) => [rule]), ...combos];
|
|
442
|
+
|
|
443
|
+
const matrix = [];
|
|
444
|
+
const coverage = []; // one row per ASSERTION: which mutants turn that call site red
|
|
445
|
+
for (const invariant of INVARIANTS) {
|
|
446
|
+
// The clean run is the census: it names every assertion this invariant executes, and
|
|
447
|
+
// whether each is declared discriminating or shape. An invariant that records nothing
|
|
448
|
+
// is one whose `run` never took the recorder — the failure mode of the injection.
|
|
449
|
+
const clean = await runOne(invariant, []);
|
|
450
|
+
if (clean.broke) {
|
|
451
|
+
problems.push(`${invariant.id}: fails against the unmutated handler `
|
|
452
|
+
+ `(${(clean.failed[0] && `:${clean.failed[0].site} ${clean.failed[0].why}`) || clean.threw})`);
|
|
453
|
+
}
|
|
454
|
+
if (!clean.sites.length) {
|
|
455
|
+
problems.push(`${invariant.id}: recorded no assertion — its run() does not take the `
|
|
456
|
+
+ '`assert` the runner passes it, so nothing about it is measured');
|
|
457
|
+
}
|
|
458
|
+
const broken = new Map(clean.sites.map((s) => [s.site, []]));
|
|
459
|
+
|
|
460
|
+
const singles = [];
|
|
461
|
+
for (const rule of RULES) {
|
|
462
|
+
const r = await runOne(invariant, [rule]);
|
|
463
|
+
if (r.broke) singles.push(rule);
|
|
464
|
+
for (const s of r.failed) if (broken.has(s.site)) broken.get(s.site).push(rule);
|
|
465
|
+
}
|
|
466
|
+
// Minimal mutants only: a combination containing a rule that already breaks the
|
|
467
|
+
// assertion on its own says nothing new.
|
|
468
|
+
const minimalCombos = [];
|
|
469
|
+
for (const combo of combos) {
|
|
470
|
+
if (combo.some((rule) => singles.includes(rule))) continue;
|
|
471
|
+
const r = await runOne(invariant, combo);
|
|
472
|
+
if (r.broke) minimalCombos.push(combo);
|
|
473
|
+
for (const s of r.failed) if (broken.has(s.site)) broken.get(s.site).push(key(combo));
|
|
474
|
+
}
|
|
475
|
+
const broke = [...singles.map((r) => [r]), ...minimalCombos];
|
|
476
|
+
matrix.push({ id: invariant.id, broke, singles });
|
|
477
|
+
|
|
478
|
+
for (const site of clean.sites) {
|
|
479
|
+
coverage.push({
|
|
480
|
+
invariant: invariant.id,
|
|
481
|
+
site: site.site,
|
|
482
|
+
unmutated: site.unmutated,
|
|
483
|
+
by: broken.get(site.site) || [],
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const expected = invariant.breaks
|
|
488
|
+
.map((b) => key(Array.isArray(b) ? b : [b])).sort().join(', ');
|
|
489
|
+
const measured = broke.map(key).sort().join(', ');
|
|
490
|
+
if (expected !== measured) {
|
|
491
|
+
problems.push(
|
|
492
|
+
`${invariant.id}: declares breaks [${expected || '-'}], measured [${measured || '-'}]`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (broke.length === 0) {
|
|
496
|
+
problems.push(`${invariant.id}: no mutant breaks it — the assertion cannot fail`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const width = Math.max(...matrix.map((row) => row.id.length));
|
|
501
|
+
console.log(`\ninvariant x mutant — ${mutants.length} mutants, `
|
|
502
|
+
+ 'and the minimal removal that turns each assertion red\n');
|
|
503
|
+
for (const row of matrix) {
|
|
504
|
+
console.log(` ${row.id.padEnd(width)} ${row.broke.map(key).join(', ') || '(nothing)'}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The per-ASSERTION half, and the one the per-invariant table above cannot give: an
|
|
508
|
+
// invariant goes red as a unit, so a dead assertion inside a live one is invisible there.
|
|
509
|
+
console.log('\nassertion x mutant — every call site, and what turns IT red\n');
|
|
510
|
+
for (const row of coverage) {
|
|
511
|
+
const label = `${row.invariant}:${row.site}`.padEnd(width + 6);
|
|
512
|
+
if (row.unmutated) {
|
|
513
|
+
console.log(` ${label} unmutated — no rule varies what it checks`);
|
|
514
|
+
if (row.by.length) {
|
|
515
|
+
problems.push(`${row.invariant} :${row.site} is declared assert.unmutated and `
|
|
516
|
+
+ `[${row.by.join(', ')}] breaks it — it discriminates, so drop the .unmutated`);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
console.log(` ${label} ${row.by.join(', ') || '(nothing)'}`);
|
|
520
|
+
if (!row.by.length) {
|
|
521
|
+
problems.push(`${row.invariant} :${row.site} is broken by no mutant — the assertion `
|
|
522
|
+
+ 'cannot fail. Either it is not testing anything, or no rule varies what it '
|
|
523
|
+
+ 'checks and it belongs on assert.unmutated, which the verdict counts apart');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
console.log('\nisolation — the fixture that separates each rule from every other\n');
|
|
529
|
+
for (const rule of RULES) {
|
|
530
|
+
const isolating = matrix.filter((row) => {
|
|
531
|
+
const exact = row.broke.filter((m) => m.length === 1 && m[0] === rule);
|
|
532
|
+
if (exact.length !== 1) return false;
|
|
533
|
+
// The broad baseline is allowed to co-occur: it breaks every grant assertion by
|
|
534
|
+
// construction, so it hides nothing. Any OTHER co-occurring rule does.
|
|
535
|
+
return row.broke
|
|
536
|
+
.filter((m) => !(m.length === 1 && m[0] === rule))
|
|
537
|
+
.every((m) => m.every((r) => BROAD.includes(r)));
|
|
538
|
+
});
|
|
539
|
+
if (isolating.length) {
|
|
540
|
+
console.log(` ${rule.padEnd(28)} ${isolating.map((r) => r.id).join(', ')}`);
|
|
541
|
+
} else {
|
|
542
|
+
problems.push(`no fixture isolates ${rule} — every assertion it breaks is also `
|
|
543
|
+
+ 'broken by another rule, so neither is proven');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (problems.length) {
|
|
548
|
+
console.error(`\nFAIL: ${problems.length} problem(s) with the assertion pack itself`);
|
|
549
|
+
for (const p of problems) console.error(` - ${p}`);
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
const pinned = coverage.filter((r) => r.unmutated).length;
|
|
553
|
+
console.log(`\nOK: ${INVARIANTS.length} invariants over ${coverage.length} assertions — `
|
|
554
|
+
+ `${coverage.length - pinned} watched failing ONE CALL SITE AT A TIME against `
|
|
555
|
+
+ `${mutants.length} mutants; ${pinned} declared unmutated and measured unbreakable; `
|
|
556
|
+
+ `${RULES.length} rules, each isolated by a fixture`);
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
561
|
+
if (isMain) {
|
|
562
|
+
const mode = process.argv.includes('--self-test') ? selfTest : runAll;
|
|
563
|
+
process.exit(await mode());
|
|
564
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_PLACEHOLDER_refund_first",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2026-07-29.dahlia",
|
|
5
|
+
"created": 1770076800,
|
|
6
|
+
"livemode": false,
|
|
7
|
+
"pending_webhooks": 1,
|
|
8
|
+
"request": {
|
|
9
|
+
"id": "req_PLACEHOLDER",
|
|
10
|
+
"idempotency_key": null
|
|
11
|
+
},
|
|
12
|
+
"type": "charge.refunded",
|
|
13
|
+
"data": {
|
|
14
|
+
"object": {
|
|
15
|
+
"id": "ch_PLACEHOLDER_creditpack",
|
|
16
|
+
"object": "charge",
|
|
17
|
+
"amount": 9000,
|
|
18
|
+
"amount_captured": 9000,
|
|
19
|
+
"amount_refunded": 4000,
|
|
20
|
+
"captured": true,
|
|
21
|
+
"paid": true,
|
|
22
|
+
"refunded": false,
|
|
23
|
+
"status": "succeeded",
|
|
24
|
+
"currency": "usd",
|
|
25
|
+
"customer": "cus_PLACEHOLDER_alice",
|
|
26
|
+
"invoice": null,
|
|
27
|
+
"payment_intent": "pi_PLACEHOLDER_creditpack",
|
|
28
|
+
"metadata": {
|
|
29
|
+
"userId": "usr_PLACEHOLDER_alice",
|
|
30
|
+
"productId": "prod_PLACEHOLDER_credits_90"
|
|
31
|
+
},
|
|
32
|
+
"refunds": {
|
|
33
|
+
"object": "list",
|
|
34
|
+
"has_more": false,
|
|
35
|
+
"total_count": 1,
|
|
36
|
+
"data": [
|
|
37
|
+
{
|
|
38
|
+
"id": "re_PLACEHOLDER_first",
|
|
39
|
+
"object": "refund",
|
|
40
|
+
"amount": 4000,
|
|
41
|
+
"charge": "ch_PLACEHOLDER_creditpack",
|
|
42
|
+
"created": 1770076800,
|
|
43
|
+
"currency": "usd",
|
|
44
|
+
"payment_intent": "pi_PLACEHOLDER_creditpack",
|
|
45
|
+
"reason": "requested_by_customer",
|
|
46
|
+
"status": "succeeded"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_PLACEHOLDER_refund_second",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2026-07-29.dahlia",
|
|
5
|
+
"created": 1770163200,
|
|
6
|
+
"livemode": false,
|
|
7
|
+
"pending_webhooks": 1,
|
|
8
|
+
"request": {
|
|
9
|
+
"id": "req_PLACEHOLDER",
|
|
10
|
+
"idempotency_key": null
|
|
11
|
+
},
|
|
12
|
+
"type": "charge.refunded",
|
|
13
|
+
"data": {
|
|
14
|
+
"object": {
|
|
15
|
+
"id": "ch_PLACEHOLDER_creditpack",
|
|
16
|
+
"object": "charge",
|
|
17
|
+
"amount": 9000,
|
|
18
|
+
"amount_captured": 9000,
|
|
19
|
+
"amount_refunded": 9000,
|
|
20
|
+
"captured": true,
|
|
21
|
+
"paid": true,
|
|
22
|
+
"refunded": true,
|
|
23
|
+
"status": "succeeded",
|
|
24
|
+
"currency": "usd",
|
|
25
|
+
"customer": "cus_PLACEHOLDER_alice",
|
|
26
|
+
"invoice": null,
|
|
27
|
+
"payment_intent": "pi_PLACEHOLDER_creditpack",
|
|
28
|
+
"metadata": {
|
|
29
|
+
"userId": "usr_PLACEHOLDER_alice",
|
|
30
|
+
"productId": "prod_PLACEHOLDER_credits_90"
|
|
31
|
+
},
|
|
32
|
+
"refunds": {
|
|
33
|
+
"object": "list",
|
|
34
|
+
"has_more": false,
|
|
35
|
+
"total_count": 2,
|
|
36
|
+
"data": [
|
|
37
|
+
{
|
|
38
|
+
"id": "re_PLACEHOLDER_second",
|
|
39
|
+
"object": "refund",
|
|
40
|
+
"amount": 5000,
|
|
41
|
+
"charge": "ch_PLACEHOLDER_creditpack",
|
|
42
|
+
"created": 1770163200,
|
|
43
|
+
"currency": "usd",
|
|
44
|
+
"payment_intent": "pi_PLACEHOLDER_creditpack",
|
|
45
|
+
"reason": "requested_by_customer",
|
|
46
|
+
"status": "succeeded"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "re_PLACEHOLDER_first",
|
|
50
|
+
"object": "refund",
|
|
51
|
+
"amount": 4000,
|
|
52
|
+
"charge": "ch_PLACEHOLDER_creditpack",
|
|
53
|
+
"created": 1770076800,
|
|
54
|
+
"currency": "usd",
|
|
55
|
+
"payment_intent": "pi_PLACEHOLDER_creditpack",
|
|
56
|
+
"reason": "requested_by_customer",
|
|
57
|
+
"status": "succeeded"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|