@bongos/core 1.21.171 → 1.21.173
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 +35 -30
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +10 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +81 -78
- package/docs/copy-registry.json +123 -96
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -1
- package/docs/page-readings.json +91 -90
- package/modules/copy-desk/page-status.js +34 -0
- package/modules/copy-desk/tests/copy_page_next.mjs +30 -2
- package/modules/hall-ui/public/studio-lib.js +13 -1
- package/modules/hall-ui/public/studio.js +9 -1
- package/modules/public-landing/public/index.html +9 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/bongos/auth-github.js +6 -1
- package/src/bongos/module-listing.js +76 -11
- package/src/bongos/routes/auth.js +6 -2
- package/src/bongos/routes/modules.js +15 -1
- package/src/module-api.js +1 -1
- package/tests/auth_switch_account.mjs +38 -0
- package/tests/hall_studio_home.mjs +24 -0
- package/tests/module_store_listing.mjs +105 -5
- package/tests/speciality_injection.mjs +20 -0
|
@@ -234,7 +234,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
|
|
|
234
234
|
// Web Flow (browser)
|
|
235
235
|
// -------------------------------------------------------------------------
|
|
236
236
|
|
|
237
|
-
function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
|
|
237
|
+
function buildAuthorizeUrl(state, redirectUri, { repos = false, switchAccount = false } = {}) {
|
|
238
238
|
const creds = loadGithubCreds();
|
|
239
239
|
if (!creds) throw new Error('GitHub OAuth credentials not configured');
|
|
240
240
|
const u = new URL('https://github.com/login/oauth/authorize');
|
|
@@ -244,6 +244,11 @@ function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
|
|
|
244
244
|
// other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
|
|
245
245
|
u.searchParams.set('scope', scopeForWebFlow({ repos }));
|
|
246
246
|
u.searchParams.set('state', state);
|
|
247
|
+
// `switchAccount:true` (the home page's "New account" button, task 1004789):
|
|
248
|
+
// GitHub otherwise silently re-uses whoever is logged in to github.com in this
|
|
249
|
+
// browser, so a second account could never be reached. select_account makes
|
|
250
|
+
// GitHub show its account picker, with "Use a different account".
|
|
251
|
+
if (switchAccount) u.searchParams.set('prompt', 'select_account');
|
|
247
252
|
return u.toString();
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -126,6 +126,54 @@ function sortByPart(entries, part) {
|
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// The ranking: quality first, price breaks ties (task 1003808, owner 2026-10-23)
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
//
|
|
133
|
+
// Above the floor, the default order of `listed` is the rule an author and a buyer
|
|
134
|
+
// can both check in one sentence:
|
|
135
|
+
//
|
|
136
|
+
// highest overall score first; between equal scores the cheaper module first
|
|
137
|
+
// (free first, then the lowest cash price, then the lowest credit price); then
|
|
138
|
+
// the key.
|
|
139
|
+
//
|
|
140
|
+
// Price never buys a better place: it only orders modules the score could not tell
|
|
141
|
+
// apart (price is never an input to the score, ADR 0343 D6). A buyer may also set a
|
|
142
|
+
// maximum price, which leaves out what costs more — a filter, not a ranking.
|
|
143
|
+
const RANKING = 'Highest overall score first; between equal scores, free modules and then the cheapest come first.';
|
|
144
|
+
|
|
145
|
+
// A module's current price row ({ price_cents, price_credits } or none) as the
|
|
146
|
+
// catalog shows it. No row is free (isFreePrice, module-store.js); 0 in either
|
|
147
|
+
// currency is free; null means not offered in that currency (core_257). Pure.
|
|
148
|
+
function priceOf(row) {
|
|
149
|
+
const cents = row && row.price_cents != null ? Number(row.price_cents) : null;
|
|
150
|
+
const credits = row && row.price_credits != null ? Number(row.price_credits) : null;
|
|
151
|
+
return { cents, credits, free: !row || cents === 0 || credits === 0 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Within a buyer's maximum ({ cents, credits }, either may be null): a free module
|
|
155
|
+
// always is; a priced one is when it is offered at or under a cap the buyer set. Pure.
|
|
156
|
+
function withinMaxPrice(price, max) {
|
|
157
|
+
if (!max || (max.cents == null && max.credits == null)) return true;
|
|
158
|
+
if (price.free) return true;
|
|
159
|
+
return (max.cents != null && price.cents != null && price.cents <= max.cents)
|
|
160
|
+
|| (max.credits != null && price.credits != null && price.credits <= max.credits);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The default order of `listed`, per RANKING. Returns a new array. Pure.
|
|
164
|
+
function rankListed(entries) {
|
|
165
|
+
const cheap = (n) => (n == null ? Infinity : n);
|
|
166
|
+
return [...entries].sort((a, b) => {
|
|
167
|
+
const pa = a.price || priceOf(null);
|
|
168
|
+
const pb = b.price || priceOf(null);
|
|
169
|
+
return ((b.overall ?? -1) - (a.overall ?? -1))
|
|
170
|
+
|| (Number(pb.free) - Number(pa.free))
|
|
171
|
+
|| (cheap(pa.cents) - cheap(pb.cents))
|
|
172
|
+
|| (cheap(pa.credits) - cheap(pb.credits))
|
|
173
|
+
|| a.key.localeCompare(b.key);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
129
177
|
// The newest version row per module key, in semver order (a version that does not
|
|
130
178
|
// parse is skipped, as latestVersions does in module-store.js).
|
|
131
179
|
function newestPerModule(rows) {
|
|
@@ -140,13 +188,14 @@ function newestPerModule(rows) {
|
|
|
140
188
|
return [...out.values()];
|
|
141
189
|
}
|
|
142
190
|
|
|
143
|
-
// The catalog: { floor, sort, sort_parts, listed: [...], new: [...] },
|
|
144
|
-
// { key, title, version, published_at, overall, is_new, parts,
|
|
145
|
-
// unmeasured } (the last four from readParts). Excluded
|
|
146
|
-
// out entirely: a module below the bar is not listed at all
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
191
|
+
// The catalog: { floor, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
|
|
192
|
+
// each entry { key, title, version, published_at, price, overall, is_new, parts,
|
|
193
|
+
// strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
|
|
194
|
+
// versions are left out entirely: a module below the bar is not listed at all, and
|
|
195
|
+
// neither is one over the buyer's `maxPrice`. `listed` is in rankListed order unless
|
|
196
|
+
// `sort` names a part, which orders it by sortByPart; `new` is never ranked and
|
|
197
|
+
// stays in key order.
|
|
198
|
+
async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice = null } = {}) {
|
|
150
199
|
const pool = db || require('./pool').pool;
|
|
151
200
|
const { rows: versions } = await pool.query(
|
|
152
201
|
`SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
|
|
@@ -162,6 +211,15 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
|
|
|
162
211
|
ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
|
|
163
212
|
for (const s of rows) scores.set(String(s.version_id), s);
|
|
164
213
|
}
|
|
214
|
+
const prices = new Map();
|
|
215
|
+
if (newest.length) {
|
|
216
|
+
const { rows } = await pool.query(
|
|
217
|
+
`SELECT DISTINCT ON (module_key) module_key, price_cents, price_credits
|
|
218
|
+
FROM store_module_prices
|
|
219
|
+
WHERE module_key = ANY($1)
|
|
220
|
+
ORDER BY module_key, set_at DESC, id DESC`, [newest.map((r) => r.module_key)]);
|
|
221
|
+
for (const p of rows) prices.set(p.module_key, p);
|
|
222
|
+
}
|
|
165
223
|
// Each version's current result per part: the newest row per (version, part),
|
|
166
224
|
// the read idx_module_assessment_signals_current exists for (core_265).
|
|
167
225
|
const signals = new Map();
|
|
@@ -177,19 +235,26 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
|
|
|
177
235
|
signals.get(k).push(s);
|
|
178
236
|
}
|
|
179
237
|
}
|
|
180
|
-
const
|
|
238
|
+
const max = maxPrice && (maxPrice.cents != null || maxPrice.credits != null)
|
|
239
|
+
? { cents: maxPrice.cents ?? null, credits: maxPrice.credits ?? null } : null;
|
|
240
|
+
const out = { floor, ranking: RANKING, sort: sort || null, sort_parts: SORT_PARTS, max_price: max, listed: [], new: [] };
|
|
181
241
|
for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
|
|
182
242
|
const score = scores.get(String(r.version_id)) || null;
|
|
183
243
|
const { placement } = placeVersion(score, floor);
|
|
184
244
|
if (placement === 'excluded') continue;
|
|
245
|
+
const price = priceOf(prices.get(r.module_key));
|
|
246
|
+
if (!withinMaxPrice(price, max)) continue;
|
|
185
247
|
out[placement].push({
|
|
186
|
-
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
248
|
+
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at, price,
|
|
187
249
|
overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
|
|
188
250
|
...readParts(signals.get(String(r.version_id)) || [], { floor }),
|
|
189
251
|
});
|
|
190
252
|
}
|
|
191
|
-
|
|
253
|
+
out.listed = sort ? sortByPart(out.listed, sort) : rankListed(out.listed);
|
|
192
254
|
return out;
|
|
193
255
|
}
|
|
194
256
|
|
|
195
|
-
module.exports = {
|
|
257
|
+
module.exports = {
|
|
258
|
+
QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, placeVersion, readParts, sortByPart,
|
|
259
|
+
priceOf, withinMaxPrice, rankListed, listCatalog,
|
|
260
|
+
};
|
|
@@ -697,6 +697,9 @@ module.exports = function buildAuthRouter() {
|
|
|
697
697
|
return sendAuthNotConfigured(res);
|
|
698
698
|
}
|
|
699
699
|
const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
|
|
700
|
+
// ?switch=1 — sign in as a DIFFERENT GitHub account (task 1004789): show
|
|
701
|
+
// GitHub's account picker instead of re-using the one logged in there.
|
|
702
|
+
const wantsSwitch = req.query.switch === '1' || req.query.switch === 'true';
|
|
700
703
|
// A sign-in begun on a satellite hub host (builders./status.) restarts on
|
|
701
704
|
// the canonical host (task 1004357). The handshake cookies are host-only now,
|
|
702
705
|
// and the callback always lands on the canonical host, so a state cookie set
|
|
@@ -708,13 +711,14 @@ module.exports = function buildAuthRouter() {
|
|
|
708
711
|
const back = raw.startsWith('/') ? `https://${host}${raw}` : raw;
|
|
709
712
|
const params = new URLSearchParams({ return: back });
|
|
710
713
|
if (wantsRepos) params.set('repos', '1');
|
|
714
|
+
if (wantsSwitch) params.set('switch', '1');
|
|
711
715
|
return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/web/start?${params}`);
|
|
712
716
|
}
|
|
713
717
|
// Already signed in on the canonical host and headed for a satellite hub
|
|
714
718
|
// host (a builder who signed in on the apex, then opened builders.): no
|
|
715
719
|
// GitHub round-trip, just hand that host its copy. A repo-scope sign-in
|
|
716
720
|
// still goes to GitHub — it exists to ask for the broader grant.
|
|
717
|
-
if (!wantsRepos) {
|
|
721
|
+
if (!wantsRepos && !wantsSwitch) {
|
|
718
722
|
const target = safeReturnTo(req.query.return);
|
|
719
723
|
if (target && !target.startsWith('/')) {
|
|
720
724
|
try {
|
|
@@ -774,7 +778,7 @@ module.exports = function buildAuthRouter() {
|
|
|
774
778
|
// way — the callback validates it identically, so the hub leg is CSRF-protected too.
|
|
775
779
|
res.redirect(auth.idpConfigured()
|
|
776
780
|
? auth.buildIdpAuthorizeUrl(state, redirectUri)
|
|
777
|
-
: auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos }));
|
|
781
|
+
: auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos, switchAccount: wantsSwitch }));
|
|
778
782
|
});
|
|
779
783
|
|
|
780
784
|
// GET /auth/web/handoff?code=&return= — the far side of the one-time session
|
|
@@ -841,6 +841,9 @@ module.exports = function buildModulesRouter() {
|
|
|
841
841
|
// is not listed at all. The floor is fixed in module-listing.js and returned here.
|
|
842
842
|
// `?sort=<part>` orders the listed modules by the part a buyer cares about
|
|
843
843
|
// (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
|
|
844
|
+
// Without it the listed modules are ranked quality first, price breaking ties
|
|
845
|
+
// (task 1003808, rankListed); `?max_cents=` / `?max_credits=` leave out modules
|
|
846
|
+
// that cost more.
|
|
844
847
|
//
|
|
845
848
|
// rank: any-builder — a read of public store metadata, like /store/modules/latest:
|
|
846
849
|
// no entitlement taken, nothing changed.
|
|
@@ -852,7 +855,18 @@ module.exports = function buildModulesRouter() {
|
|
|
852
855
|
const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
|
|
853
856
|
return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
|
|
854
857
|
}
|
|
855
|
-
|
|
858
|
+
// An optional maximum price (task 1003808): ?max_cents= and/or ?max_credits=,
|
|
859
|
+
// each a whole number of at least 0. Free modules always pass.
|
|
860
|
+
const maxPrice = {};
|
|
861
|
+
for (const [param, field] of [['max_cents', 'cents'], ['max_credits', 'credits']]) {
|
|
862
|
+
const v = req.query && req.query[param];
|
|
863
|
+
if (v == null || v === '') continue;
|
|
864
|
+
if (typeof v !== 'string' || !/^\d{1,9}$/.test(v)) {
|
|
865
|
+
return res.fail('bad_request', { status: 400, message: `${param} is a maximum price: a whole number, 0 or more.` });
|
|
866
|
+
}
|
|
867
|
+
maxPrice[field] = Number(v);
|
|
868
|
+
}
|
|
869
|
+
const listing = await listCatalog({ sort, maxPrice });
|
|
856
870
|
res.set('Cache-Control', 'no-store');
|
|
857
871
|
res.json(listing);
|
|
858
872
|
}));
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.173'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// tests/auth_switch_account.mjs — the home page's "New account" door (task 1004789).
|
|
2
|
+
//
|
|
3
|
+
// WHY. GitHub silently re-uses whoever is logged in to github.com in the browser,
|
|
4
|
+
// so a second Cloud Bongos account was unreachable. ?switch=1 on /auth/web/start
|
|
5
|
+
// must add prompt=select_account (GitHub's account picker); a plain sign-in must not.
|
|
6
|
+
import { strict as assert } from 'node:assert';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'test-client';
|
|
16
|
+
process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'test-secret';
|
|
17
|
+
const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
|
|
18
|
+
|
|
19
|
+
test('switchAccount adds prompt=select_account; a plain sign-in does not', () => {
|
|
20
|
+
const plain = new URL(auth.buildAuthorizeUrl('s', 'https://x/cb'));
|
|
21
|
+
assert.equal(plain.searchParams.get('prompt'), null);
|
|
22
|
+
const sw = new URL(auth.buildAuthorizeUrl('s', 'https://x/cb', { switchAccount: true }));
|
|
23
|
+
assert.equal(sw.searchParams.get('prompt'), 'select_account');
|
|
24
|
+
assert.equal(sw.searchParams.get('scope'), plain.searchParams.get('scope'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('the start route reads ?switch=1 and passes it to the GitHub leg', () => {
|
|
28
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/auth.js'), 'utf8');
|
|
29
|
+
assert.match(src, /const wantsSwitch = req\.query\.switch === '1'/);
|
|
30
|
+
assert.match(src, /switchAccount: wantsSwitch/);
|
|
31
|
+
// a switch must reach GitHub, never short-circuit through the live-session handoff
|
|
32
|
+
assert.match(src, /if \(!wantsRepos && !wantsSwitch\) \{/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the landing carries the New account door beside Sign in', () => {
|
|
36
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/index.html'), 'utf8');
|
|
37
|
+
assert.match(html, /<a class="pill newacct" href="\/api\/bongos\/auth\/web\/start\?return=\/&repos=1&switch=1"[^>]*>New account<\/a>/);
|
|
38
|
+
});
|
|
@@ -360,6 +360,30 @@ try {
|
|
|
360
360
|
assert.ok(!s.html.classList.contains('st-open'));
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
+
// task 1004670: the worst-pages report, through the real composer into the
|
|
364
|
+
// real Studio panel: the pages with the most text, most first.
|
|
365
|
+
await test('Most words to write: the worst-pages report, most text first, linked to the editor', async () => {
|
|
366
|
+
const T = (key, text) => ({ key, section: 's', text, placement: 'placed' });
|
|
367
|
+
const readings = { pages: INV.pages.map((p, i) => ({ id: p.id, reading_hash: `r${i}`, lines: Array.from({ length: i + 2 }, (_, k) => T(`L${k}`, `${p.id} line ${k}`)) })) };
|
|
368
|
+
const pagesWithReadings = ps.composePages({ inventory: INV, readings, tasks: TASKS });
|
|
369
|
+
assert.ok(pagesWithReadings.worst_pages.length >= 1);
|
|
370
|
+
const top = pagesWithReadings.worst_pages[0];
|
|
371
|
+
assert.equal(top.page_id, INV.pages[INV.pages.length - 1].id, 'the page with the most text leads');
|
|
372
|
+
const rows = lib.worstRows(pagesWithReadings);
|
|
373
|
+
assert.equal(rows[0].size, `${top.text_lines} lines of text`);
|
|
374
|
+
const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages': ok(pagesWithReadings) } });
|
|
375
|
+
await s.settle();
|
|
376
|
+
await s.click('st-status');
|
|
377
|
+
const body = s.el('st-panel-body').innerHTML;
|
|
378
|
+
assert.match(body, /Most words to write/);
|
|
379
|
+
assert.match(body, new RegExp(`${top.text_lines} lines of text`));
|
|
380
|
+
assert.match(body, new RegExp(`href="/builders/tweak-editor\\?page=${top.page_id}"`));
|
|
381
|
+
const plain = bootStudio({ answers: HOME });
|
|
382
|
+
await plain.settle();
|
|
383
|
+
await plain.click('st-status');
|
|
384
|
+
assert.doesNotMatch(plain.el('st-panel-body').innerHTML, /Most words to write/, 'no readings, no report');
|
|
385
|
+
});
|
|
386
|
+
|
|
363
387
|
await test('the studio\'s words: a first click offers the studio page, and nothing is taken until the artist says so', async () => {
|
|
364
388
|
const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: false })) } });
|
|
365
389
|
await s.settle();
|
|
@@ -15,9 +15,18 @@ import { createRequire } from 'node:module';
|
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
|
|
17
17
|
// ---- the fake registry the route reads ----
|
|
18
|
-
const state = { modules: {}, versions: [], scores: [], signals: [] };
|
|
18
|
+
const state = { modules: {}, versions: [], scores: [], signals: [], prices: [] };
|
|
19
19
|
const pool = {
|
|
20
20
|
async query(sql, params = []) {
|
|
21
|
+
if (/FROM store_module_prices/.test(sql)) {
|
|
22
|
+
const newest = new Map();
|
|
23
|
+
for (const p of state.prices) {
|
|
24
|
+
if (!params[0].includes(p.module_key)) continue;
|
|
25
|
+
const have = newest.get(p.module_key);
|
|
26
|
+
if (!have || p.set_at > have.set_at) newest.set(p.module_key, p);
|
|
27
|
+
}
|
|
28
|
+
return { rows: [...newest.values()] };
|
|
29
|
+
}
|
|
21
30
|
if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
|
|
22
31
|
return {
|
|
23
32
|
rows: state.versions
|
|
@@ -53,7 +62,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
|
|
|
53
62
|
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
54
63
|
|
|
55
64
|
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
56
|
-
const { QUALITY_FLOOR, SORT_PARTS, placeVersion, sortByPart } = require('../src/bongos/module-listing.js');
|
|
65
|
+
const { QUALITY_FLOOR, SORT_PARTS, RANKING, placeVersion, sortByPart, priceOf, withinMaxPrice, rankListed } = require('../src/bongos/module-listing.js');
|
|
57
66
|
|
|
58
67
|
function handlerFor(router, method, p) {
|
|
59
68
|
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
@@ -153,7 +162,7 @@ test('GET /store/modules: lists by the newest version\'s current score, drops wh
|
|
|
153
162
|
assert.equal(res.statusCode, 200);
|
|
154
163
|
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
155
164
|
assert.equal(res.body.floor, 50);
|
|
156
|
-
assert.deepEqual(res.body.listed.map((m) => [m.key, m.version, m.overall]), [['
|
|
165
|
+
assert.deepEqual(res.body.listed.map((m) => [m.key, m.version, m.overall]), [['good', '1.10.0', 80], ['fixed', '2.0.0', 60]], 'highest score first');
|
|
157
166
|
assert.deepEqual(res.body.new.map((m) => [m.key, m.is_new, m.overall]), [['fresh', true, null]]);
|
|
158
167
|
const all = [...res.body.listed, ...res.body.new].map((m) => m.key);
|
|
159
168
|
for (const k of ['low', 'unsafe', 'unscored', 'gone']) assert.ok(!all.includes(k), `${k} must not be listed`);
|
|
@@ -163,7 +172,7 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
|
|
|
163
172
|
state.modules = {}; state.versions = []; state.scores = [];
|
|
164
173
|
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
165
174
|
const res = await invoke(handle, {});
|
|
166
|
-
assert.deepEqual(res.body, { floor: 50, sort: null, sort_parts: SORT_PARTS, listed: [], new: [] });
|
|
175
|
+
assert.deepEqual(res.body, { floor: 50, ranking: RANKING, sort: null, sort_parts: SORT_PARTS, max_price: null, listed: [], new: [] });
|
|
167
176
|
});
|
|
168
177
|
|
|
169
178
|
// ---------------------------------------------------------------------------
|
|
@@ -229,7 +238,7 @@ test('GET /store/modules?sort=: orders the listed modules by the part, leaves Ne
|
|
|
229
238
|
assert.deepEqual(byTests.body.listed.map((m) => m.key), ['tested', 'steady']);
|
|
230
239
|
const plain = await invoke(handle, { query: {} });
|
|
231
240
|
assert.equal(plain.body.sort, null);
|
|
232
|
-
assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is
|
|
241
|
+
assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is the ranking: equal scores, both free, so key order');
|
|
233
242
|
state.signals = [];
|
|
234
243
|
});
|
|
235
244
|
|
|
@@ -307,3 +316,94 @@ test('the criterion: two modules with the same overall score read differently',
|
|
|
307
316
|
assert.equal(by.steady.parts.reliability.score, 90);
|
|
308
317
|
state.signals = [];
|
|
309
318
|
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// the ranking: quality first, price breaks ties, optional max price (task 1003808)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
test('priceOf: no price row is free; 0 in either currency is free; null is not offered', () => {
|
|
325
|
+
assert.deepEqual(priceOf(null), { cents: null, credits: null, free: true });
|
|
326
|
+
assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, credits: null, free: true });
|
|
327
|
+
assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, credits: 0, free: true });
|
|
328
|
+
assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, credits: null, free: false });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('rankListed: a higher score always wins, whatever it costs — price never buys a place', () => {
|
|
332
|
+
const ranked = rankListed([
|
|
333
|
+
{ key: 'cheap', overall: 70, price: priceOf(null) },
|
|
334
|
+
{ key: 'dear', overall: 90, price: priceOf({ price_cents: 99900 }) },
|
|
335
|
+
]);
|
|
336
|
+
assert.deepEqual(ranked.map((e) => e.key), ['dear', 'cheap']);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('rankListed: between equal scores, free first, then lowest cash, then lowest credits, then key', () => {
|
|
340
|
+
const input = [
|
|
341
|
+
{ key: 'z-credits', overall: 80, price: priceOf({ price_credits: 5 }) },
|
|
342
|
+
{ key: 'cents-900', overall: 80, price: priceOf({ price_cents: 900 }) },
|
|
343
|
+
{ key: 'cents-100', overall: 80, price: priceOf({ price_cents: 100 }) },
|
|
344
|
+
{ key: 'b-free', overall: 80, price: priceOf(null) },
|
|
345
|
+
{ key: 'a-free', overall: 80, price: priceOf({ price_cents: 0 }) },
|
|
346
|
+
];
|
|
347
|
+
assert.deepEqual(rankListed(input).map((e) => e.key), ['a-free', 'b-free', 'cents-100', 'cents-900', 'z-credits']);
|
|
348
|
+
assert.equal(input[0].key, 'z-credits', 'the input is untouched');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('withinMaxPrice: free always passes; a priced module must be offered at or under a cap the buyer set', () => {
|
|
352
|
+
const p = priceOf({ price_cents: 500, price_credits: 7 });
|
|
353
|
+
assert.equal(withinMaxPrice(p, null), true, 'no cap, no filter');
|
|
354
|
+
assert.equal(withinMaxPrice(p, { cents: 500, credits: null }), true, 'at the cap is in');
|
|
355
|
+
assert.equal(withinMaxPrice(p, { cents: 499, credits: null }), false);
|
|
356
|
+
assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), true, 'either currency under its cap is enough');
|
|
357
|
+
assert.equal(withinMaxPrice(priceOf({ price_cents: 500 }), { cents: null, credits: 100 }), false, 'not offered in credits, so a credit cap cannot let it in');
|
|
358
|
+
assert.equal(withinMaxPrice(priceOf(null), { cents: 0, credits: null }), true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out what costs more', async () => {
|
|
362
|
+
state.modules = {
|
|
363
|
+
top: { status: 'listed', title: 'Top' }, 'tie-paid': { status: 'listed', title: 'Tie paid' },
|
|
364
|
+
'tie-free': { status: 'listed', title: 'Tie free' }, fresh: { status: 'listed', title: 'Fresh' },
|
|
365
|
+
};
|
|
366
|
+
state.versions = [
|
|
367
|
+
{ id: 31, module_key: 'top', version: '1.0.0', published_at: 't' },
|
|
368
|
+
{ id: 32, module_key: 'tie-paid', version: '1.0.0', published_at: 't' },
|
|
369
|
+
{ id: 33, module_key: 'tie-free', version: '1.0.0', published_at: 't' },
|
|
370
|
+
{ id: 34, module_key: 'fresh', version: '1.0.0', published_at: 't' },
|
|
371
|
+
];
|
|
372
|
+
state.scores = [
|
|
373
|
+
{ id: 31, version_id: 31, computed_at: 1, ...score({ overall: 92 }) },
|
|
374
|
+
{ id: 32, version_id: 32, computed_at: 1, ...score({ overall: 75 }) },
|
|
375
|
+
{ id: 33, version_id: 33, computed_at: 1, ...score({ overall: 75 }) },
|
|
376
|
+
{ id: 34, version_id: 34, computed_at: 1, ...score({ is_new: true }) },
|
|
377
|
+
];
|
|
378
|
+
state.signals = [];
|
|
379
|
+
state.prices = [
|
|
380
|
+
{ module_key: 'top', price_cents: 2000, price_credits: null, set_at: 1 },
|
|
381
|
+
{ module_key: 'tie-paid', price_cents: 900, price_credits: null, set_at: 1 },
|
|
382
|
+
{ module_key: 'tie-paid', price_cents: 500, price_credits: null, set_at: 2 },
|
|
383
|
+
{ module_key: 'fresh', price_cents: 3000, price_credits: null, set_at: 1 },
|
|
384
|
+
];
|
|
385
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
386
|
+
const all = await invoke(handle, { query: {} });
|
|
387
|
+
assert.equal(all.body.ranking, RANKING);
|
|
388
|
+
assert.deepEqual(all.body.listed.map((m) => m.key), ['top', 'tie-free', 'tie-paid']);
|
|
389
|
+
assert.deepEqual(all.body.listed.map((m) => m.price.cents), [2000, null, 500], 'the CURRENT price is shown');
|
|
390
|
+
assert.equal(all.body.max_price, null);
|
|
391
|
+
|
|
392
|
+
const capped = await invoke(handle, { query: { max_cents: '1000' } });
|
|
393
|
+
assert.deepEqual(capped.body.max_price, { cents: 1000, credits: null });
|
|
394
|
+
assert.deepEqual(capped.body.listed.map((m) => m.key), ['tie-free', 'tie-paid']);
|
|
395
|
+
assert.deepEqual(capped.body.new, [], 'the cap applies to New modules too');
|
|
396
|
+
|
|
397
|
+
const free = await invoke(handle, { query: { max_cents: '0' } });
|
|
398
|
+
assert.deepEqual(free.body.listed.map((m) => m.key), ['tie-free'], 'a cap of 0 is free only');
|
|
399
|
+
state.prices = [];
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('GET /store/modules: a maximum price that is not a whole number of 0 or more is refused', async () => {
|
|
403
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
404
|
+
for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_credits: 'ten' }, { max_cents: ['1', '2'] }]) {
|
|
405
|
+
const res = await invoke(handle, { query });
|
|
406
|
+
assert.equal(res.statusCode, 400, JSON.stringify(query));
|
|
407
|
+
assert.match(res.body.message, /maximum price/);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
@@ -105,3 +105,23 @@ test('the adoption read selects the contract itself', () => {
|
|
|
105
105
|
// while still looking well-formed.
|
|
106
106
|
assert.match(src('modules/specialities/db.js'), /SELECT s\.id, s\.slug, s\.name, s\.discipline, s\.summary, s\.contract_md/);
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
// task 1004817: two sessions, two worktrees, two claims in two crafts. Each
|
|
110
|
+
// session gets ONLY the contract for the claim bound to its own worktree; the
|
|
111
|
+
// account-wide pool is only the fallback when no claim is bound here.
|
|
112
|
+
test('two claims in two worktrees: each session gets only its own contract', () => {
|
|
113
|
+
const claims = [
|
|
114
|
+
{ ...claim('artist'), task_id: '1', worktree_name: 'task-1' },
|
|
115
|
+
{ ...claim('engineer'), task_id: '2', worktree_name: 'task-2' },
|
|
116
|
+
];
|
|
117
|
+
const specs = [
|
|
118
|
+
sp({ discipline: 'artist', contract: 'Speciality: Pixel art (under artist).' }),
|
|
119
|
+
sp({ discipline: 'engineer', contract: 'Speciality: Compilers (under engineer).' }),
|
|
120
|
+
];
|
|
121
|
+
const got = (worktree) => ids(conductor.evaluateTriggers({ activeClaims: claims, specialities: specs, worktree }))
|
|
122
|
+
.filter((i) => String(i).startsWith('speciality:'));
|
|
123
|
+
assert.deepEqual(got('task-1'), ['speciality:artist']);
|
|
124
|
+
assert.deepEqual(got('task-2'), ['speciality:engineer']);
|
|
125
|
+
// No claim bound to this tree (main checkout): today's behaviour, every craft in play.
|
|
126
|
+
assert.deepEqual(got('main-checkout').sort(), ['speciality:artist', 'speciality:engineer']);
|
|
127
|
+
});
|