@bongos/core 1.21.153 → 1.21.155

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.
@@ -9940,5 +9940,21 @@
9940
9940
  "id": "1004766",
9941
9941
  "text": "When a project's server can't talk to GitHub, the land-status check now says which step failed and what GitHub answered, instead of a bare error."
9942
9942
  }
9943
+ ],
9944
+ "1.21.154": [
9945
+ {
9946
+ "id": "1003800",
9947
+ "text": "In the module store, a buyer can now sort by the score that matters to them, like reliability or tests, so the module strongest at that comes first."
9948
+ }
9949
+ ],
9950
+ "1.21.155": [
9951
+ {
9952
+ "id": "1004769",
9953
+ "text": "The person who built something can now sign off its UAT themselves, not only the project owner. The old rule is paused, not deleted, so it can come back with one change."
9954
+ },
9955
+ {
9956
+ "id": "1004768",
9957
+ "text": "A page rewrite can now include a long paragraph. Before, any paragraph over 400 characters was dropped from the site's text list, so the privacy page's changes could not be applied."
9958
+ }
9943
9959
  ]
9944
9960
  }
@@ -66,7 +66,11 @@ const SURFACES = [
66
66
  // Attributes whose value is COPY a person reads (not wiring). The matchers are
67
67
  // built ONCE — the first cut re-created all seven per markup literal.
68
68
  const COPY_ATTRS = ['title', 'aria-label', 'placeholder', 'alt', 'data-page-title', 'aria-description'];
69
- const COPY_ATTR_RES = COPY_ATTRS.map((attr) => ({ attr, re: new RegExp(`\\b${attr}\\s*=\\s*"([^"]{4,400})"`, 'gi') }));
69
+ // The longest string the registry lists. A page tweak may rewrite a paragraph
70
+ // past the old 400, and the applier then could not find its own words in the
71
+ // registry it regenerates (task 1004768, the privacy page's round).
72
+ const MAX_COPY_LEN = 2000;
73
+ const COPY_ATTR_RES = COPY_ATTRS.map((attr) => ({ attr, re: new RegExp(`\\b${attr}\\s*=\\s*"([^"]{4,${MAX_COPY_LEN}})"`, 'gi') }));
70
74
 
71
75
  // What this tool knowingly does not see. Printed in the report — an inventory that
72
76
  // implies completeness it does not have is worse than a short one.
@@ -125,7 +129,7 @@ function lineAt(starts, index) {
125
129
  // by platform (task 1004545 — a 396-char run was 403 under CRLF and vanished).
126
130
  function looksLikeCopy(raw) {
127
131
  const s = String(raw).replace(/\r\n/g, '\n').trim();
128
- if (s.length < 4 || s.length > 400) return false;
132
+ if (s.length < 4 || s.length > MAX_COPY_LEN) return false;
129
133
  if (!/[A-Za-z]{2}/.test(s)) return false; // needs actual letters
130
134
  if (!/\s/.test(s) && !/[.!?]$/.test(s)) return false; // one bare word is a token
131
135
  if (/^[/.#]/.test(s)) return false; // paths, selectors
@@ -561,7 +565,7 @@ if (require.main === module) {
561
565
  }
562
566
 
563
567
  module.exports = {
564
- SURFACES, COPY_ATTRS, BLIND_SPOTS, REGISTRY_REL, REPORT_REL,
568
+ SURFACES, COPY_ATTRS, BLIND_SPOTS, MAX_COPY_LEN, REGISTRY_REL, REPORT_REL,
565
569
  cmp, stableId, looksLikeCopy, lineIndex, lineAt, stripEmbedded, fromHtml, fromJs, filesFor,
566
570
  buildRegistry, renderReport, write, matchesGenerated,
567
571
  };
@@ -92,6 +92,40 @@ function readParts(signals = [], { floor = QUALITY_FLOOR } = {}) {
92
92
  return { parts, strengths, weaknesses, unmeasured };
93
93
  }
94
94
 
95
+ // ---------------------------------------------------------------------------
96
+ // Sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
97
+ // ---------------------------------------------------------------------------
98
+ //
99
+ // A module strong at what a buyer needs should come first for THAT buyer. Nothing
100
+ // records what a category is "for", and a hidden per-category weighting would break
101
+ // the score an author can check (ADR 0343 D3), so the owner chose: the buyer picks
102
+ // the part. The rule, in a sentence an author can check:
103
+ //
104
+ // modules with a score for the part come first, highest first; a tie goes to the
105
+ // higher overall score, then the key. A module with no score for the part goes
106
+ // last, in key order: a missing part is never a zero, and never a first place.
107
+ //
108
+ // Only `listed` is sorted. A "New" version is never ranked (owner decision,
109
+ // 2026-10-01), so `new` keeps key order whatever the buyer picks. Today this runs
110
+ // over the whole store; once approved sub-categories exist (task 1004355) the same
111
+ // rule orders each one (ADR 0342 D2).
112
+ const SORT_PARTS = PARTS.map(({ part, title }) => ({ part, title }));
113
+
114
+ // `entries` carry `parts` as readParts builds them. Returns a new array. Pure.
115
+ function sortByPart(entries, part) {
116
+ const scoreOf = (e) => (e.parts && e.parts[part] ? e.parts[part].score : null);
117
+ return [...entries].sort((a, b) => {
118
+ const sa = scoreOf(a);
119
+ const sb = scoreOf(b);
120
+ if (sa == null || sb == null) {
121
+ if (sa != null) return -1;
122
+ if (sb != null) return 1;
123
+ return a.key.localeCompare(b.key);
124
+ }
125
+ return (sb - sa) || ((b.overall ?? -1) - (a.overall ?? -1)) || a.key.localeCompare(b.key);
126
+ });
127
+ }
128
+
95
129
  // The newest version row per module key, in semver order (a version that does not
96
130
  // parse is skipped, as latestVersions does in module-store.js).
97
131
  function newestPerModule(rows) {
@@ -106,12 +140,13 @@ function newestPerModule(rows) {
106
140
  return [...out.values()];
107
141
  }
108
142
 
109
- // The catalog: { floor, listed: [...], new: [...] }, each entry
143
+ // The catalog: { floor, sort, sort_parts, listed: [...], new: [...] }, each entry
110
144
  // { key, title, version, published_at, overall, is_new, parts, strengths, weaknesses,
111
145
  // unmeasured } (the last four from readParts). Excluded versions are left
112
146
  // out entirely: a module below the bar is not listed at all. Both lists are in key
113
- // order; ranking is a separate task (1003808) and does not happen here.
114
- async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
147
+ // order unless `sort` names a part, which orders `listed` by sortByPart; ranking
148
+ // is a separate task (1003808) and does not happen here.
149
+ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
115
150
  const pool = db || require('./pool').pool;
116
151
  const { rows: versions } = await pool.query(
117
152
  `SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
@@ -142,7 +177,7 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
142
177
  signals.get(k).push(s);
143
178
  }
144
179
  }
145
- const out = { floor, listed: [], new: [] };
180
+ const out = { floor, sort: sort || null, sort_parts: SORT_PARTS, listed: [], new: [] };
146
181
  for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
147
182
  const score = scores.get(String(r.version_id)) || null;
148
183
  const { placement } = placeVersion(score, floor);
@@ -153,7 +188,8 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
153
188
  ...readParts(signals.get(String(r.version_id)) || [], { floor }),
154
189
  });
155
190
  }
191
+ if (sort) out.listed = sortByPart(out.listed, sort);
156
192
  return out;
157
193
  }
158
194
 
159
- module.exports = { QUALITY_FLOOR, STRONG_AT, placeVersion, readParts, listCatalog };
195
+ module.exports = { QUALITY_FLOOR, STRONG_AT, SORT_PARTS, placeVersion, readParts, sortByPart, listCatalog };
@@ -110,7 +110,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
110
110
  // property access on a CommonJS namespace, so reaching these through one made them read
111
111
  // as unused exports and tripped the dead-code ratchet that held this branch out of main.
112
112
  const { listOverrides, setOverride } = require('../module-overrides');
113
- const { listCatalog } = require('../module-listing');
113
+ const { listCatalog, SORT_PARTS } = require('../module-listing');
114
114
 
115
115
  // The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
116
116
  // module's pages, 'pollers' its background jobs. PURE.
@@ -733,12 +733,20 @@ module.exports = function buildModulesRouter() {
733
733
  // listed module's newest version, if it passed the Security gate and scores at or
734
734
  // above the floor, or under "new" if it has no real-world data yet. Below the bar
735
735
  // is not listed at all. The floor is fixed in module-listing.js and returned here.
736
+ // `?sort=<part>` orders the listed modules by the part a buyer cares about
737
+ // (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
736
738
  //
737
739
  // rank: any-builder — a read of public store metadata, like /store/modules/latest:
738
740
  // no entitlement taken, nothing changed.
739
741
  router.get('/store/modules', auth.requireBuilder,
740
- asyncHandler('GET /store/modules', async (_req, res) => {
741
- const listing = await listCatalog();
742
+ asyncHandler('GET /store/modules', async (req, res) => {
743
+ const raw = req.query && req.query.sort;
744
+ const sort = typeof raw === 'string' && raw ? raw : null;
745
+ if (sort && !SORT_PARTS.some((p) => p.part === sort)) {
746
+ const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
747
+ return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
748
+ }
749
+ const listing = await listCatalog({ sort });
742
750
  res.set('Cache-Control', 'no-store');
743
751
  res.json(listing);
744
752
  }));
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.153'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.155'; // 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');
@@ -103,15 +103,18 @@ test('stripEmbedded keeps line numbers truthful', () => {
103
103
  // Windows regen dropped a row CI kept and redded "the committed artifacts match".
104
104
  // The fixture rebuilds both halves: a tag inside a comment, and a run sized to
105
105
  // straddle the cap.
106
- // Eight 45-char lines under a 28-char head: 396 chars on LF, 404 on CRLF.
107
- const NEAR_CAP_LINES = Array.from({ length: 8 }, () => ` ${'word '.repeat(8)}s.`);
106
+ // 45-char lines under a 28-char head, as many as fit under the cap on LF; each
107
+ // CRLF adds one, which pushes it over. Sized from MAX_COPY_LEN (task 1004768).
108
+ const NEAR_CAP_HEAD = 'A real sentence on the page,';
109
+ const NEAR_CAP_LINE = ` ${'word '.repeat(8)}s.`;
110
+ const NEAR_CAP_LINES = Array.from({ length: Math.floor((inv.MAX_COPY_LEN - NEAR_CAP_HEAD.length) / (NEAR_CAP_LINE.length + 1)) }, () => NEAR_CAP_LINE);
108
111
  const EOL_FIXTURE = [
109
112
  '<!doctype html>',
110
113
  '<!-- Author note: a building is a door (an <a>) to its interior.',
111
114
  ' The template is street.css, the <style> on the tokens.',
112
115
  '-->',
113
116
  '<style>.x { color: red; }</style>',
114
- '<p>A real sentence on the page,',
117
+ `<p>${NEAR_CAP_HEAD}`,
115
118
  ...NEAR_CAP_LINES.slice(0, -1),
116
119
  `${NEAR_CAP_LINES.at(-1)}</p>`,
117
120
  '<p title="A tooltip a person reads">Second real line.</p>',
@@ -123,13 +126,26 @@ test('LF and CRLF checkouts extract identical rows (task 1004545)', () => {
123
126
  const strip = (rows) => rows.map(({ text, line, origin }) => ({ text, line, origin }));
124
127
  assert.deepEqual(strip(crlf), strip(lf), 'the registry must not depend on the checkout\'s line endings');
125
128
  const run = EOL_FIXTURE.slice(EOL_FIXTURE.indexOf('A real'), EOL_FIXTURE.indexOf('</p>'));
126
- assert.ok(run.length <= 400 && run.replace(/\n/g, '\r\n').length > 400, 'the fixture must straddle the cap, or this test proves nothing');
129
+ assert.ok(run.length <= inv.MAX_COPY_LEN && run.replace(/\n/g, '\r\n').length > inv.MAX_COPY_LEN, 'the fixture must straddle the cap, or this test proves nothing');
127
130
  // The multi-line sentence sits under the cap only when CRLF counts as one
128
131
  // character — so this is the case that reds if the length rule regresses.
129
132
  assert.ok(lf.some((r) => r.text.startsWith('A real sentence on the page')), 'a multi-line run under the cap is copy on LF');
130
133
  assert.ok(crlf.some((r) => r.text.startsWith('A real sentence on the page')), 'and the same run is copy on CRLF');
131
134
  });
132
135
 
136
+ // The privacy page's round rewrote one paragraph past 400 characters, and the
137
+ // registry dropped it, so the applier could not find its own words (task 1004768).
138
+ test('a long paragraph a page tweak writes is still listed', () => {
139
+ const para = `We keep only what we need to run your account. ${'Nothing else is stored or sold, and you can ask for it to be removed. '.repeat(14)}`.trim();
140
+ assert.ok(para.length > 1000 && para.length <= inv.MAX_COPY_LEN, 'the fixture must sit well past the old 400 cap');
141
+ const rows = inv.fromHtml({ source: `<p>${para}</p>\n<p title="${para}">x y</p>`, rel: 'p.html' });
142
+ assert.equal(rows.filter((r) => r.text === para).length, 2, 'listed as text and as an attribute');
143
+ const at = (n) => `${'ab '.repeat(n)}`.slice(0, n - 1) + '.';
144
+ assert.equal(at(inv.MAX_COPY_LEN).length, inv.MAX_COPY_LEN);
145
+ assert.equal(inv.looksLikeCopy(at(inv.MAX_COPY_LEN)), true, 'exactly at the cap is listed');
146
+ assert.equal(inv.looksLikeCopy(at(inv.MAX_COPY_LEN + 1)), false, 'one past the cap is refused');
147
+ });
148
+
133
149
  test('HTML comments are never copy, and a tag named inside one swallows nothing', () => {
134
150
  const rows = inv.fromHtml({ source: EOL_FIXTURE, rel: 'p.html' });
135
151
  const texts = rows.map((r) => r.text);
@@ -140,7 +156,7 @@ test('HTML comments are never copy, and a tag named inside one swallows nothing'
140
156
  assert.ok(texts.includes('Second real line.'));
141
157
  assert.ok(texts.includes('A tooltip a person reads'));
142
158
  const real = rows.find((r) => r.text === 'Second real line.');
143
- assert.equal(real.line, 15, 'a stripped comment must not shift the lines after it');
159
+ assert.equal(real.line, 7 + NEAR_CAP_LINES.length, 'a stripped comment must not shift the lines after it');
144
160
  // A span still addresses the file's own characters (copy-apply writes through it).
145
161
  assert.equal(EOL_FIXTURE.slice(real.start, real.end), 'Second real line.');
146
162
  });
@@ -29,6 +29,8 @@ import { makeRunner } from './helpers.mjs';
29
29
 
30
30
  process.env.NODE_ENV = 'test';
31
31
  const require = createRequire(import.meta.url);
32
+ // The shipper rule is paused in production (owner, 2026-10-22); prove it still works.
33
+ process.env.UAT_SELF_SIGNOFF = 'off';
32
34
  const uat = require('../modules/lifecycle/criterion-uat.js');
33
35
  const uatDb = require('../modules/lifecycle/criterion-uat-db.js');
34
36
  const doneWhen = require('../modules/lifecycle/done-when.js');
@@ -115,6 +117,23 @@ await test('LADDER: whoever shipped linked work is refused — ids compare as st
115
117
  assert.equal(code({ signerId: '8' }), 'signer_shipped_this_work');
116
118
  });
117
119
 
120
+ await test('PAUSE (task 1004769): at the production default a shipper is let through, and only the shipper rung moves', () => {
121
+ const modPath = require.resolve('../modules/lifecycle/criterion-uat.js');
122
+ delete process.env.UAT_SELF_SIGNOFF;
123
+ delete require.cache[modPath];
124
+ try {
125
+ const paused = require(modPath);
126
+ const pcode = (over) => { const r = paused.signoffRefusal({ ...base, ...over }); return r && r.code; };
127
+ assert.equal(pcode({ signerId: 7 }), null);
128
+ assert.equal(pcode({ signerId: '8' }), null);
129
+ assert.equal(pcode({ signerId: 7, recording: null }), 'uat_recording_required');
130
+ } finally {
131
+ process.env.UAT_SELF_SIGNOFF = 'off';
132
+ delete require.cache[modPath];
133
+ require(modPath);
134
+ }
135
+ });
136
+
118
137
  await test('LADDER: the project owner may sign off work they shipped (solo projects)', () => {
119
138
  assert.equal(code({ signerId: 3, shipperIds: ['3'] }), null);
120
139
  });
@@ -94,7 +94,7 @@ test('modules: the store group reads strengths and weaknesses off the server row
94
94
  const html = read('modules.html');
95
95
  assert.match(html, /<div class="ov-group" id="store-group" hidden>/, 'the store group is hidden until the store answers');
96
96
  assert.match(html, /<ul class="ov-rows" id="store-grid"><\/ul>/);
97
- assert.match(js, /api\.request\('GET', `\$\{API\}\/store\/modules`\)/, 'it reads GET /store/modules');
97
+ assert.match(js, /api\.request\('GET', `\$\{API\}\/store\/modules\$\{q\}`\)/, 'it reads GET /store/modules');
98
98
  assert.match(js, /<span class="ov-fact">strong at \$\{partsList\(strong\)\}<\/span>/, 'a strength is a plain fact');
99
99
  assert.match(js, /<span class="fact-pill fact-pill--warn">weak at \$\{partsList\(weak\)\}<\/span>/, 'a weakness is the warn pill');
100
100
  assert.match(js, /not measured yet/, 'a part with no data is named, never counted as weak');
@@ -106,6 +106,18 @@ test('modules: the store group reads strengths and weaknesses off the server row
106
106
  assert.notDeepEqual(two[0].strengths.map((s) => s.part), two[1].strengths.map((s) => s.part), '…that are strong at different things');
107
107
  });
108
108
 
109
+ test('modules: the buyer sorts the store by the part that matters to them (task 1003800)', () => {
110
+ const js = read('modules.js');
111
+ const html = read('modules.html');
112
+ assert.match(html, /<select id="store-sort"><option value="">Name<\/option><\/select>/, 'the menu starts at name order');
113
+ assert.match(js, /\?sort=\$\{encodeURIComponent\(storeSort\)\}/, 'the server orders the list; the page asks for a part');
114
+ assert.match(js, /fillStoreSort\(body\.sort_parts\)/, 'the parts on the menu are the server\'s list');
115
+ assert.doesNotMatch(decomment(js), /\.sort\(\(a, b\)/, 'the page does not re-sort the store rows itself');
116
+ assert.match(js, /not measured yet<\/span>/, 'a row with no score for the sorted part says so, not a zero');
117
+ const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'store__modules.json'), 'utf8'));
118
+ assert.deepEqual(fixture.sort_parts.map((p) => p.part), ['tests', 'install', 'reliability', 'tester_feedback', 'docs'], 'the harness carries the menu');
119
+ });
120
+
109
121
  test('modules: the kit filter bar carries the search and a state lens, and the empty state is the kit\'s', () => {
110
122
  const js = read('modules.js');
111
123
  assert.match(js, /kit\.makeFilterBar\(\{/, 'the bar is the kit\'s');
@@ -53,7 +53,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
53
53
  require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
54
54
 
55
55
  const buildModulesRouter = require('../src/bongos/routes/modules.js');
56
- const { QUALITY_FLOOR, placeVersion } = require('../src/bongos/module-listing.js');
56
+ const { QUALITY_FLOOR, SORT_PARTS, placeVersion, sortByPart } = require('../src/bongos/module-listing.js');
57
57
 
58
58
  function handlerFor(router, method, p) {
59
59
  const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
@@ -163,7 +163,83 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
163
163
  state.modules = {}; state.versions = []; state.scores = [];
164
164
  const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
165
165
  const res = await invoke(handle, {});
166
- assert.deepEqual(res.body, { floor: 50, listed: [], new: [] });
166
+ assert.deepEqual(res.body, { floor: 50, sort: null, sort_parts: SORT_PARTS, listed: [], new: [] });
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const entry = (key, overall, partScores) => ({
174
+ key, overall,
175
+ parts: Object.fromEntries(Object.entries(partScores).map(([p, s]) => [p, { outcome: s == null ? 'no_data' : 'scored', score: s, sample_size: null }])),
176
+ });
177
+
178
+ test('sortByPart: highest first for the chosen part; a tie goes to the overall score, then the key', () => {
179
+ const out = sortByPart([
180
+ entry('b-mod', 70, { reliability: 90 }),
181
+ entry('a-mod', 70, { reliability: 90 }),
182
+ entry('steady', 60, { reliability: 95 }),
183
+ entry('higher', 80, { reliability: 90 }),
184
+ ], 'reliability');
185
+ assert.deepEqual(out.map((e) => e.key), ['steady', 'higher', 'a-mod', 'b-mod']);
186
+ });
187
+
188
+ test('sortByPart: a module with no score for the part goes last, never first, and the input is untouched', () => {
189
+ const input = [entry('z-unmeasured', 99, { reliability: null }), entry('low', 55, { reliability: 10 }), entry('a-missing', 99, {})];
190
+ const out = sortByPart(input, 'reliability');
191
+ assert.deepEqual(out.map((e) => e.key), ['low', 'a-missing', 'z-unmeasured']);
192
+ assert.equal(input[0].key, 'z-unmeasured', 'a new array; the listing it came from keeps its order');
193
+ });
194
+
195
+ test('SORT_PARTS: every part a buyer compares, Security not among them', () => {
196
+ assert.deepEqual(SORT_PARTS.map((p) => p.part), ['tests', 'install', 'reliability', 'tester_feedback', 'docs']);
197
+ });
198
+
199
+ test('GET /store/modules?sort=: orders the listed modules by the part, leaves New in key order', async () => {
200
+ state.modules = {
201
+ tested: { status: 'listed', title: 'Tested' }, steady: { status: 'listed', title: 'Steady' },
202
+ 'b-new': { status: 'listed', title: 'B new' }, 'a-new': { status: 'listed', title: 'A new' },
203
+ };
204
+ state.versions = [
205
+ { id: 21, module_key: 'tested', version: '1.0.0', published_at: 't' },
206
+ { id: 22, module_key: 'steady', version: '1.0.0', published_at: 't' },
207
+ { id: 23, module_key: 'b-new', version: '1.0.0', published_at: 't' },
208
+ { id: 24, module_key: 'a-new', version: '1.0.0', published_at: 't' },
209
+ ];
210
+ state.scores = [
211
+ { id: 21, version_id: 21, computed_at: 1, ...score({ overall: 74 }) },
212
+ { id: 22, version_id: 22, computed_at: 1, ...score({ overall: 74 }) },
213
+ { id: 23, version_id: 23, computed_at: 1, ...score({ is_new: true }) },
214
+ { id: 24, version_id: 24, computed_at: 1, ...score({ is_new: true }) },
215
+ ];
216
+ state.signals = [
217
+ { id: 21, version_id: 21, measured_at: 1, ...sig('tests', 96) },
218
+ { id: 22, version_id: 21, measured_at: 1, ...sig('reliability', 52) },
219
+ { id: 23, version_id: 22, measured_at: 1, ...sig('tests', 58) },
220
+ { id: 24, version_id: 22, measured_at: 1, ...sig('reliability', 91) },
221
+ { id: 25, version_id: 23, measured_at: 1, ...sig('reliability', 99) },
222
+ ];
223
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
224
+ const byRel = await invoke(handle, { query: { sort: 'reliability' } });
225
+ assert.equal(byRel.body.sort, 'reliability');
226
+ assert.deepEqual(byRel.body.listed.map((m) => m.key), ['steady', 'tested'], 'the buyer who cares about reliability sees Steady first');
227
+ assert.deepEqual(byRel.body.new.map((m) => m.key), ['a-new', 'b-new'], 'New is never ranked, whatever the buyer picks');
228
+ const byTests = await invoke(handle, { query: { sort: 'tests' } });
229
+ assert.deepEqual(byTests.body.listed.map((m) => m.key), ['tested', 'steady']);
230
+ const plain = await invoke(handle, { query: {} });
231
+ assert.equal(plain.body.sort, null);
232
+ assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is key order, as before');
233
+ state.signals = [];
234
+ });
235
+
236
+ test('GET /store/modules?sort=: a part it does not know is refused, Security included', async () => {
237
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
238
+ for (const sort of ['security', 'price', 'overall']) {
239
+ const res = await invoke(handle, { query: { sort } });
240
+ assert.equal(res.statusCode, 400, `${sort} is not a part to sort by`);
241
+ assert.match(res.body.message, /Tests \(tests\), Clean installs \(install\), Reliability \(reliability\), Tester feedback \(tester_feedback\), How-to \(docs\)/, 'the refusal names each part as a person says it, with the value to send');
242
+ }
167
243
  });
168
244
 
169
245
  // ---------------------------------------------------------------------------