@bongos/core 1.24.8 → 1.24.9
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/.bongos-core.json +36 -31
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
- package/docs/api/openapi.json +7 -4
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/copy-inventory.md +14 -12
- package/docs/copy-registry.json +31 -13
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +3 -3
- package/docs/page-readings.json +3 -3
- package/migrations/core_287_module_prices_cash_only.sql +24 -0
- package/modules/hall-ui/public/modules.js +5 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/bongos/module-store-cli.js +21 -21
- package/scripts/bongos/module.js +8 -8
- package/src/bongos/module-listing.js +47 -20
- package/src/bongos/module-store.js +20 -12
- package/src/bongos/routes/modules.js +31 -21
- package/src/module-api.js +1 -1
- package/tests/module_store_cli.mjs +14 -3
- package/tests/module_store_listing.mjs +65 -14
- package/tests/module_store_price.mjs +97 -26
- package/tests/module_store_registry_migration.mjs +11 -0
|
@@ -347,11 +347,27 @@ test('the criterion: two modules with the same overall score read differently',
|
|
|
347
347
|
// the ranking: quality first, price breaks ties, optional max price (task 1003808)
|
|
348
348
|
// ---------------------------------------------------------------------------
|
|
349
349
|
|
|
350
|
-
test('priceOf: no price row is free;
|
|
351
|
-
assert.deepEqual(priceOf(null), { cents: null,
|
|
352
|
-
assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0,
|
|
353
|
-
assert.deepEqual(priceOf({ price_cents: 500, price_credits:
|
|
354
|
-
|
|
350
|
+
test('priceOf: no price row is free; a cash price of 0 is free; never a credit amount', () => {
|
|
351
|
+
assert.deepEqual(priceOf(null), { cents: null, free: true, coming: false });
|
|
352
|
+
assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, free: true, coming: false });
|
|
353
|
+
assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, free: false, coming: false });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Task 1004989 (ADR 0385 decision 2; owner, 2026-10-29): OLD rows may carry a
|
|
357
|
+
// credit price. They are read, never shown as a credit price, and never buyable
|
|
358
|
+
// with credits.
|
|
359
|
+
test('priceOf on old credit rows: credit-only is "price coming", 0 credits stays free, cash wins', () => {
|
|
360
|
+
assert.deepEqual(priceOf({ price_cents: null, price_credits: 40 }), { cents: null, free: false, coming: true },
|
|
361
|
+
'a module priced only in credits is still listed, with its cash price coming');
|
|
362
|
+
assert.deepEqual(priceOf({ price_cents: null, price_credits: 0 }), { cents: null, free: true, coming: false },
|
|
363
|
+
'an old 0-credit offer stays free: no credit moves to have it');
|
|
364
|
+
assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, free: true, coming: false },
|
|
365
|
+
'an old row offered at 0 credits stays free, as it was');
|
|
366
|
+
assert.deepEqual(priceOf({ price_cents: 500, price_credits: 40 }), { cents: 500, free: false, coming: false },
|
|
367
|
+
'with a cash price the credit amount is ignored');
|
|
368
|
+
for (const row of [{ price_credits: 40 }, { price_cents: 500, price_credits: 40 }]) {
|
|
369
|
+
assert.equal('credits' in priceOf(row), false, 'no credit amount leaves priceOf');
|
|
370
|
+
}
|
|
355
371
|
});
|
|
356
372
|
|
|
357
373
|
test('rankListed: a higher score always wins, whatever it costs — price never buys a place', () => {
|
|
@@ -362,7 +378,7 @@ test('rankListed: a higher score always wins, whatever it costs — price never
|
|
|
362
378
|
assert.deepEqual(ranked.map((e) => e.key), ['dear', 'cheap']);
|
|
363
379
|
});
|
|
364
380
|
|
|
365
|
-
test('rankListed: between equal scores, free first, then lowest cash,
|
|
381
|
+
test('rankListed: between equal scores, free first, then lowest cash, a price still coming last, then key', () => {
|
|
366
382
|
const input = [
|
|
367
383
|
{ key: 'z-credits', overall: 80, price: priceOf({ price_credits: 5 }) },
|
|
368
384
|
{ key: 'cents-900', overall: 80, price: priceOf({ price_cents: 900 }) },
|
|
@@ -374,14 +390,14 @@ test('rankListed: between equal scores, free first, then lowest cash, then lowes
|
|
|
374
390
|
assert.equal(input[0].key, 'z-credits', 'the input is untouched');
|
|
375
391
|
});
|
|
376
392
|
|
|
377
|
-
test('withinMaxPrice: free always passes; a priced module must
|
|
393
|
+
test('withinMaxPrice: free always passes; a priced module must cost at or under the cash cap', () => {
|
|
378
394
|
const p = priceOf({ price_cents: 500, price_credits: 7 });
|
|
379
395
|
assert.equal(withinMaxPrice(p, null), true, 'no cap, no filter');
|
|
380
|
-
assert.equal(withinMaxPrice(p, { cents: 500
|
|
381
|
-
assert.equal(withinMaxPrice(p, { cents: 499
|
|
382
|
-
assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }),
|
|
383
|
-
assert.equal(withinMaxPrice(priceOf({
|
|
384
|
-
assert.equal(withinMaxPrice(priceOf(null), { cents: 0
|
|
396
|
+
assert.equal(withinMaxPrice(p, { cents: 500 }), true, 'at the cap is in');
|
|
397
|
+
assert.equal(withinMaxPrice(p, { cents: 499 }), false);
|
|
398
|
+
assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), false, 'a credit cap is ignored: credits never price a module');
|
|
399
|
+
assert.equal(withinMaxPrice(priceOf({ price_credits: 7 }), { cents: 100000 }), false, 'a price still coming has no cash price to compare');
|
|
400
|
+
assert.equal(withinMaxPrice(priceOf(null), { cents: 0 }), true);
|
|
385
401
|
});
|
|
386
402
|
|
|
387
403
|
test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out what costs more', async () => {
|
|
@@ -416,7 +432,7 @@ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out
|
|
|
416
432
|
assert.equal(all.body.max_price, null);
|
|
417
433
|
|
|
418
434
|
const capped = await invoke(handle, { query: { max_cents: '1000' } });
|
|
419
|
-
assert.deepEqual(capped.body.max_price, { cents: 1000
|
|
435
|
+
assert.deepEqual(capped.body.max_price, { cents: 1000 });
|
|
420
436
|
assert.deepEqual(capped.body.listed.map((m) => m.key), ['tie-free', 'tie-paid']);
|
|
421
437
|
assert.deepEqual(capped.body.new, [], 'the cap applies to New modules too');
|
|
422
438
|
|
|
@@ -427,13 +443,48 @@ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out
|
|
|
427
443
|
|
|
428
444
|
test('GET /store/modules: a maximum price that is not a whole number of 0 or more is refused', async () => {
|
|
429
445
|
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
430
|
-
for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, {
|
|
446
|
+
for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_cents: ['1', '2'] }]) {
|
|
431
447
|
const res = await invoke(handle, { query });
|
|
432
448
|
assert.equal(res.statusCode, 400, JSON.stringify(query));
|
|
433
449
|
assert.match(res.body.message, /maximum price/);
|
|
434
450
|
}
|
|
435
451
|
});
|
|
436
452
|
|
|
453
|
+
test('GET /store/modules: there is no maximum in credits (task 1004989)', async () => {
|
|
454
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
455
|
+
for (const query of [{ max_credits: '100' }, { max_credits: 'ten' }, { max_cents: '500', max_credits: '1' }]) {
|
|
456
|
+
const res = await invoke(handle, { query });
|
|
457
|
+
assert.equal(res.statusCode, 400, JSON.stringify(query));
|
|
458
|
+
assert.match(res.body.message, /Credits do not price modules/);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('GET /store/modules: an old credit-only price is listed as "price coming", never as credits, and not buyable', async () => {
|
|
463
|
+
state.modules = { legacy: { status: 'listed', title: 'Legacy' }, cash: { status: 'listed', title: 'Cash' } };
|
|
464
|
+
state.versions = [
|
|
465
|
+
{ id: 41, module_key: 'legacy', version: '1.0.0', published_at: 't' },
|
|
466
|
+
{ id: 42, module_key: 'cash', version: '1.0.0', published_at: 't' },
|
|
467
|
+
];
|
|
468
|
+
state.scores = [
|
|
469
|
+
{ id: 41, version_id: 41, computed_at: 1, ...score({ overall: 80 }) },
|
|
470
|
+
{ id: 42, version_id: 42, computed_at: 1, ...score({ overall: 80 }) },
|
|
471
|
+
];
|
|
472
|
+
state.signals = [];
|
|
473
|
+
state.prices = [
|
|
474
|
+
{ module_key: 'legacy', price_cents: null, price_credits: 40, set_at: 1 },
|
|
475
|
+
{ module_key: 'cash', price_cents: 900, price_credits: null, set_at: 1 },
|
|
476
|
+
];
|
|
477
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
478
|
+
const res = await invoke(handle, { query: {} });
|
|
479
|
+
const legacy = res.body.listed.find((m) => m.key === 'legacy');
|
|
480
|
+
assert.ok(legacy, 'a credit-priced module stays visible (owner, 2026-10-29)');
|
|
481
|
+
assert.deepEqual(legacy.price, { cents: null, free: false, coming: true });
|
|
482
|
+
assert.equal(legacy.buyable, false, 'credits never buy it');
|
|
483
|
+
assert.doesNotMatch(JSON.stringify(res.body), /credits/, 'no credit price appears anywhere in the catalog');
|
|
484
|
+
assert.deepEqual(res.body.listed.map((m) => m.key), ['cash', 'legacy'], 'between equal scores a price still coming sorts after a cash price');
|
|
485
|
+
state.prices = [];
|
|
486
|
+
});
|
|
487
|
+
|
|
437
488
|
// ---------------------------------------------------------------------------
|
|
438
489
|
// why a module is or is not listed (task 1003810)
|
|
439
490
|
// ---------------------------------------------------------------------------
|
|
@@ -30,8 +30,11 @@ let state = fresh();
|
|
|
30
30
|
const newestPrice = (key) => state.prices.filter((p) => p.module_key === key).at(-1) || null;
|
|
31
31
|
const query = async (sql, params = []) => {
|
|
32
32
|
if (/^INSERT INTO store_module_prices/.test(sql)) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Reads the column list rather than assuming it, so a writer that still sent
|
|
34
|
+
// price_credits would be recorded as such and fail the cash-only cases below.
|
|
35
|
+
const cols = sql.match(/\(([^)]*)\)/)[1].split(',').map((c) => c.trim());
|
|
36
|
+
const row = Object.fromEntries(cols.map((c, i) => [c, params[i]]));
|
|
37
|
+
state.prices.push({ price_credits: null, ...row, set_at: ++state.setAt });
|
|
35
38
|
return { rows: [] };
|
|
36
39
|
}
|
|
37
40
|
if (/FROM store_module_prices\s+WHERE module_key = \$1/.test(sql)) {
|
|
@@ -105,9 +108,9 @@ test('only the author may set the price; a stranger and a delisted module are re
|
|
|
105
108
|
assert.equal(state.prices.length, 0, 'nothing was written');
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
test('a price must be whole
|
|
111
|
+
test('a price must be a whole number of cents, 0 or more', async () => {
|
|
109
112
|
state = fresh();
|
|
110
|
-
for (const body of [{}, { cents: null
|
|
113
|
+
for (const body of [{}, { cents: null }, { cents: -1 }, { cents: 1.5 }, { cents: '10' }, { cents: 1e12 }]) {
|
|
111
114
|
const r = await setPrice(body);
|
|
112
115
|
assert.equal(r.statusCode, 400, JSON.stringify(body));
|
|
113
116
|
assert.ok(['bad_price', 'validation_failed'].includes(r.body.error.code), r.body.error.code);
|
|
@@ -115,21 +118,34 @@ test('a price must be whole numbers of 0 or more, and name at least one currency
|
|
|
115
118
|
assert.equal(state.prices.length, 0);
|
|
116
119
|
});
|
|
117
120
|
|
|
118
|
-
|
|
121
|
+
// Task 1004989 (ADR 0385 decision 2): credits never price or buy a module.
|
|
122
|
+
test('a credit price is refused by name, with or without cents, and nothing is written', async () => {
|
|
123
|
+
state = fresh();
|
|
124
|
+
for (const body of [{ credits: 40 }, { credits: 0 }, { cents: 500, credits: 40 }, { credits: '10' }]) {
|
|
125
|
+
const r = await setPrice(body);
|
|
126
|
+
assert.equal(r.statusCode, 400, JSON.stringify(body));
|
|
127
|
+
assert.equal(r.body.error.code, 'credit_price_refused', JSON.stringify(body));
|
|
128
|
+
assert.match(r.body.error.message, /Credits cannot price a module/);
|
|
129
|
+
}
|
|
130
|
+
assert.equal(state.prices.length, 0, 'no row, not even the cash half');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('each change appends a cash-only row naming who set it, and the newest is what acquire reads', async () => {
|
|
119
134
|
state = fresh();
|
|
120
135
|
const first = await setPrice({ cents: 900 });
|
|
121
136
|
assert.equal(first.statusCode, 200);
|
|
122
|
-
assert.deepEqual(first.body, { ok: true, module_key: 'weather', previous: null, price: { cents: 900
|
|
123
|
-
const second = await setPrice({ cents: 500
|
|
124
|
-
assert.deepEqual(second.body.previous, { cents: 900,
|
|
137
|
+
assert.deepEqual(first.body, { ok: true, module_key: 'weather', previous: null, price: { cents: 900 } });
|
|
138
|
+
const second = await setPrice({ cents: 500 });
|
|
139
|
+
assert.deepEqual(second.body.previous, { cents: 900, coming: false });
|
|
125
140
|
assert.equal(state.prices.length, 2, 'the history is kept');
|
|
126
141
|
assert.equal(state.prices[1].set_by, 42);
|
|
142
|
+
assert.ok(state.prices.every((p) => p.price_credits == null), 'no new row carries a credit price');
|
|
127
143
|
const acq = await store.resolveAcquirable('weather', {});
|
|
128
144
|
assert.equal(acq.ok, true);
|
|
129
|
-
assert.
|
|
145
|
+
assert.equal(acq.price.price_cents, 500);
|
|
130
146
|
assert.equal(acq.free, false);
|
|
131
|
-
await setPrice({
|
|
132
|
-
assert.equal((await store.resolveAcquirable('weather', {})).free, true, '
|
|
147
|
+
await setPrice({ cents: 0 });
|
|
148
|
+
assert.equal((await store.resolveAcquirable('weather', {})).free, true, 'a cash price of 0 is free');
|
|
133
149
|
});
|
|
134
150
|
|
|
135
151
|
test('the catalog shows the price, and marks a paid module not yet buyable until buying opens', async () => {
|
|
@@ -139,26 +155,79 @@ test('the catalog shows the price, and marks a paid module not yet buyable until
|
|
|
139
155
|
assert.equal(cat.listed[0].buyable, true, 'a free module can be had today');
|
|
140
156
|
await setPrice({ cents: 900 });
|
|
141
157
|
cat = await listing.listCatalog({});
|
|
142
|
-
assert.deepEqual(cat.listed[0].price, { cents: 900,
|
|
158
|
+
assert.deepEqual(cat.listed[0].price, { cents: 900, free: false, coming: false });
|
|
143
159
|
assert.equal(cat.listed[0].buyable, false, 'buying arrives with task 1003788');
|
|
144
160
|
});
|
|
145
161
|
|
|
146
|
-
test('
|
|
162
|
+
test('an OLD credit-only row: listed as price coming, never free, acquire refuses with no credit path, the author is asked', async () => {
|
|
163
|
+
state = fresh();
|
|
164
|
+
state.prices.push({ module_key: 'weather', price_cents: null, price_credits: 40, set_by: 42, set_at: ++state.setAt });
|
|
165
|
+
const cat = await listing.listCatalog({});
|
|
166
|
+
assert.deepEqual(cat.listed[0].price, { cents: null, free: false, coming: true });
|
|
167
|
+
assert.equal(cat.listed[0].buyable, false);
|
|
168
|
+
const acq = await store.resolveAcquirable('weather', {});
|
|
169
|
+
assert.equal(acq.free, false, 'credits never make it free or buyable');
|
|
170
|
+
// The author's listing read carries the ask; a stranger's does not.
|
|
171
|
+
const mine = await listing.readListing('weather', { viewerId: 42 });
|
|
172
|
+
assert.equal(mine.price_prompt, listing.PRICE_COMING_PROMPT);
|
|
173
|
+
assert.match(mine.price_prompt, /set a cash price/);
|
|
174
|
+
const theirs = await listing.readListing('weather', { viewerId: 7 });
|
|
175
|
+
assert.equal(theirs.price_prompt, null);
|
|
176
|
+
assert.deepEqual(theirs.price, { cents: null, free: false, coming: true });
|
|
177
|
+
// Setting a cash price replaces it; the reply says what it was.
|
|
178
|
+
const set = await setPrice({ cents: 300 });
|
|
179
|
+
assert.deepEqual(set.body.previous, { cents: null, coming: true });
|
|
180
|
+
assert.equal((await listing.readListing('weather', { viewerId: 42 })).price_prompt, null, 'the ask goes away');
|
|
181
|
+
// An old 0-credit offer stays free, as it was.
|
|
182
|
+
state = fresh();
|
|
183
|
+
state.prices.push({ module_key: 'weather', price_cents: null, price_credits: 0, set_by: 42, set_at: ++state.setAt });
|
|
184
|
+
assert.equal((await store.resolveAcquirable('weather', {})).free, true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// The hall store row: the real storeFacts lifted out of modules.js and run over a
|
|
188
|
+
// real catalog entry, so the page and the read cannot disagree about the flag.
|
|
189
|
+
test('the hall store row marks a price-coming module, and nothing else', async () => {
|
|
190
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.js'), 'utf8');
|
|
191
|
+
const start = src.indexOf(' function storeFacts(m) {');
|
|
192
|
+
const end = src.indexOf('\n }\n', start);
|
|
193
|
+
assert.ok(start > 0 && end > start, 'storeFacts is in modules.js');
|
|
194
|
+
const storeFacts = new Function('escapeHtml', 'sortedPartFact', 'partsList',
|
|
195
|
+
`${src.slice(start, end + 4)}; return storeFacts;`)((s) => String(s), () => '', () => '');
|
|
196
|
+
state = fresh();
|
|
197
|
+
state.prices.push({ module_key: 'weather', price_cents: null, price_credits: 40, set_by: 42, set_at: ++state.setAt });
|
|
198
|
+
const coming = (await listing.listCatalog({})).listed[0];
|
|
199
|
+
assert.match(storeFacts(coming), /price coming/);
|
|
200
|
+
assert.doesNotMatch(storeFacts(coming), /40/, 'the credit amount is never drawn');
|
|
201
|
+
state = fresh();
|
|
202
|
+
await setPrice({ cents: 900 });
|
|
203
|
+
assert.doesNotMatch(storeFacts((await listing.listCatalog({})).listed[0]), /price coming/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the acquire refusal never offers credits as a way to pay', () => {
|
|
207
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
208
|
+
const at = src.indexOf("'payment_required'");
|
|
209
|
+
const block = src.slice(at, at + 600);
|
|
210
|
+
assert.ok(at > 0);
|
|
211
|
+
assert.doesNotMatch(block, /credit economy/);
|
|
212
|
+
assert.match(block, /has no cash price yet/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('bongos module price: sends the cash price, says what it was and now is, and that paid is not yet buyable', async () => {
|
|
147
216
|
const sent = [];
|
|
148
217
|
const request = async (method, url, opts) => {
|
|
149
218
|
sent.push({ method, url, json: opts && opts.json });
|
|
150
|
-
return { ok: true, status: 200, data: { ok: true, previous: { cents:
|
|
219
|
+
return { ok: true, status: 200, data: { ok: true, previous: { cents: null, coming: true }, price: { cents: 900 } } };
|
|
151
220
|
};
|
|
152
221
|
const out = [];
|
|
153
|
-
assert.equal(await mod.cmdPrice(['weather', '--cents', '900'
|
|
154
|
-
assert.deepEqual(sent, [{ method: 'POST', url: '/store/modules/weather/price', json: { cents: 900
|
|
155
|
-
assert.match(out.join('\n'), /"weather" now costs \$9\.00
|
|
222
|
+
assert.equal(await mod.cmdPrice(['weather', '--cents', '900'], { log: (l) => out.push(l), errlog: () => {}, request }), 0);
|
|
223
|
+
assert.deepEqual(sent, [{ method: 'POST', url: '/store/modules/weather/price', json: { cents: 900 } }]);
|
|
224
|
+
assert.match(out.join('\n'), /"weather" now costs \$9\.00 \(was price coming\)/);
|
|
156
225
|
assert.match(out.join('\n'), /not yet buyable/);
|
|
157
226
|
});
|
|
158
227
|
|
|
159
|
-
test('bongos module price: refuses no key, no amount
|
|
228
|
+
test('bongos module price: refuses no key, no amount, a non-number and any credit price before asking the store', async () => {
|
|
160
229
|
const request = async () => { throw new Error('must not be called'); };
|
|
161
|
-
for (const args of [[], ['--cents', '5'], ['weather'], ['weather', '--cents', 'five'], ['weather', '--credits', '
|
|
230
|
+
for (const args of [[], ['--cents', '5'], ['weather'], ['weather', '--cents', 'five'], ['weather', '--credits', '40'], ['weather', '--cents', '5', '--credits', '1']]) {
|
|
162
231
|
const errs = [];
|
|
163
232
|
assert.equal(await mod.cmdPrice(args, { log: () => {}, errlog: (l) => errs.push(l), request }), 2, args.join(' '));
|
|
164
233
|
assert.match(errs.join('\n'), /usage: bongos module price/);
|
|
@@ -183,7 +252,7 @@ test('the price history is the audit: who set each price and when, and what it r
|
|
|
183
252
|
assert.match(src, /FROM store_modules WHERE module_key = \$1 FOR UPDATE/, 'one transaction, the module row locked');
|
|
184
253
|
});
|
|
185
254
|
|
|
186
|
-
test('bongos module publish --cents
|
|
255
|
+
test('bongos module publish --cents sets the price after the upload, and checks it (credits refused) before packing', async () => {
|
|
187
256
|
const packed = { tgz: Buffer.from('x'), sidecar: { module_version: '1.0.0', core_version: '^1.0.0', file_count: 1, tree_sha256: 'a'.repeat(64), tarball: { name: 'x.tgz' } } };
|
|
188
257
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-price-'));
|
|
189
258
|
fs.mkdirSync(path.join(dir, 'weather'));
|
|
@@ -195,13 +264,15 @@ test('bongos module publish --cents/--credits sets the price after the upload, a
|
|
|
195
264
|
const out = [];
|
|
196
265
|
const code = await mod.cmdPublish(['--cents', '900', 'weather'], { log: (l) => out.push(l), errlog: (l) => out.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest });
|
|
197
266
|
assert.equal(code, 0, out.join('\n'));
|
|
198
|
-
assert.deepEqual(sent, [['POST', '/store/modules/weather/price', { cents: 900
|
|
267
|
+
assert.deepEqual(sent, [['POST', '/store/modules/weather/price', { cents: 900 }]], 'the key is found even after a valued flag');
|
|
199
268
|
let packedAtAll = false;
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
269
|
+
for (const flags of [['--cents', 'nine'], ['--credits', '40']]) {
|
|
270
|
+
const bad = await mod.cmdPublish(['weather', ...flags], { log: () => {}, errlog: () => {}, modulesDir: dir, pack: () => { packedAtAll = true; return packed; }, upload, priceRequest });
|
|
271
|
+
assert.equal(bad, 2, flags.join(' '));
|
|
272
|
+
}
|
|
273
|
+
assert.equal(packedAtAll, false, 'a bad or credit price refuses before anything is packed or sent');
|
|
203
274
|
const failing = async () => ({ ok: false, status: 403, data: { error: { message: 'Only the author' } } });
|
|
204
275
|
const errs = [];
|
|
205
|
-
assert.equal(await mod.cmdPublish(['weather', '--
|
|
206
|
-
assert.match(errs.join('\n'), /The version is published, but its price was not set\. Run `bongos module price weather --
|
|
276
|
+
assert.equal(await mod.cmdPublish(['weather', '--cents', '40'], { log: () => {}, errlog: (l) => errs.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest: failing }), 1);
|
|
277
|
+
assert.match(errs.join('\n'), /The version is published, but its price was not set\. Run `bongos module price weather --cents 40`/);
|
|
207
278
|
});
|
|
@@ -43,6 +43,17 @@ test('a price of zero is allowed in both currencies — free modules are first-c
|
|
|
43
43
|
assert.match(t, /CHECK \(price_cents IS NOT NULL OR price_credits IS NOT NULL\)/);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// Task 1004989 (ADR 0385 decision 2): a NEW price row may not carry credits, and
|
|
47
|
+
// the old rows stay readable — so the CHECK is NOT VALID and must stay that way.
|
|
48
|
+
test('core_287: new price rows are cash only; old credit rows are kept, not re-checked', () => {
|
|
49
|
+
const sql = readFileSync(path.join(ROOT, 'migrations', 'core_287_module_prices_cash_only.sql'), 'utf8')
|
|
50
|
+
.replace(/--[^\n]*/g, '');
|
|
51
|
+
assert.match(sql, /ADD CONSTRAINT store_module_prices_no_new_credit_price CHECK \(price_credits IS NULL\) NOT VALID;/);
|
|
52
|
+
assert.match(sql, /DROP CONSTRAINT IF EXISTS store_module_prices_no_new_credit_price;/, 'idempotent on a re-run');
|
|
53
|
+
assert.doesNotMatch(sql, /VALIDATE CONSTRAINT|UPDATE store_module_prices|DELETE FROM store_module_prices/,
|
|
54
|
+
'no old row is validated, rewritten or deleted');
|
|
55
|
+
});
|
|
56
|
+
|
|
46
57
|
test('a version is exact semver and can be published only once per module', () => {
|
|
47
58
|
const t = table('store_module_versions');
|
|
48
59
|
assert.match(t, /version\s+text NOT NULL\s+CHECK \(version ~ '\^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$'\)/);
|