@bongos/core 1.23.1 → 1.23.3

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.
@@ -6,6 +6,8 @@
6
6
  // - the route: the real GET /store/modules handler off the real router, with
7
7
  // pool.js replaced in the require cache (the module_upgrade_updates.mjs harness).
8
8
  //
9
+ // The cap of ten per approved sub-category (task 1003809) rides the same harness.
10
+ //
9
11
  // Run: node tests/module_store_listing.mjs
10
12
 
11
13
  import { strict as assert } from 'node:assert';
@@ -15,7 +17,7 @@ import { createRequire } from 'node:module';
15
17
  const require = createRequire(import.meta.url);
16
18
 
17
19
  // ---- the fake registry the route reads ----
18
- const state = { modules: {}, versions: [], scores: [], signals: [], prices: [] };
20
+ const state = { modules: {}, versions: [], scores: [], signals: [], prices: [], categories: {}, versionReads: [] };
19
21
  const pool = {
20
22
  async query(sql, params = []) {
21
23
  if (/FROM store_module_prices/.test(sql)) {
@@ -27,10 +29,27 @@ const pool = {
27
29
  }
28
30
  return { rows: [...newest.values()] };
29
31
  }
32
+ if (/FROM store_module_categories self/.test(sql)) {
33
+ state.peerReads = (state.peerReads || 0) + 1;
34
+ const mine = state.categories[params[0]];
35
+ return {
36
+ rows: Object.keys(state.categories)
37
+ .filter((k) => mine && state.categories[k].sub_key === mine.sub_key && state.categories[k].parent === mine.parent)
38
+ .map((k) => ({ module_key: k })),
39
+ };
40
+ }
41
+ if (/FROM store_module_categories c\b/.test(sql)) {
42
+ return {
43
+ rows: params[0].filter((k) => state.categories[k])
44
+ .map((k) => ({ module_key: k, sub_key: null, label: null, status: null, ...state.categories[k] })),
45
+ };
46
+ }
30
47
  if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
48
+ state.versionReads.push(params[0] ? [...params[0]] : null);
31
49
  return {
32
50
  rows: state.versions
33
51
  .filter((v) => state.modules[v.module_key] && state.modules[v.module_key].status === 'listed')
52
+ .filter((v) => !params[0] || params[0].includes(v.module_key))
34
53
  .map((v) => ({ ...v, version_id: v.id, title: state.modules[v.module_key].title })),
35
54
  };
36
55
  }
@@ -69,7 +88,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
69
88
  require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
70
89
 
71
90
  const buildModulesRouter = require('../src/bongos/routes/modules.js');
72
- const { QUALITY_FLOOR, SORT_PARTS, RANKING, placeVersion, sortByPart, priceOf, withinMaxPrice, rankListed, explainPlacement } = require('../src/bongos/module-listing.js');
91
+ const { QUALITY_FLOOR, SORT_PARTS, RANKING, CAP_PER_SUBCATEGORY, placeVersion, sortByPart, priceOf, withinMaxPrice, rankListed, applyCap, explainPlacement } = require('../src/bongos/module-listing.js');
73
92
 
74
93
  function handlerFor(router, method, p) {
75
94
  const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
@@ -179,7 +198,7 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
179
198
  state.modules = {}; state.versions = []; state.scores = [];
180
199
  const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
181
200
  const res = await invoke(handle, {});
182
- assert.deepEqual(res.body, { floor: 50, ranking: RANKING, sort: null, sort_parts: SORT_PARTS, max_price: null, listed: [], new: [] });
201
+ assert.deepEqual(res.body, { floor: 50, cap: 10, ranking: RANKING, sort: null, sort_parts: SORT_PARTS, max_price: null, listed: [], new: [] });
183
202
  });
184
203
 
185
204
  // ---------------------------------------------------------------------------
@@ -430,6 +449,7 @@ test('explainPlacement: every reason says why in a sentence and what would chang
430
449
  [{ placement: 'excluded', reason: 'delisted' }, {}],
431
450
  [{ placement: 'excluded', reason: 'no_version' }, {}],
432
451
  [{ placement: 'new', reason: 'new' }, {}],
452
+ [{ placement: 'excluded', reason: 'over_cap' }, { overall: 95, held: { rank: 11, cap: 10, label: 'Stripe payments' } }],
433
453
  ]) {
434
454
  const e = explainPlacement(placed, ctx);
435
455
  assert.equal(e.reason, placed.reason);
@@ -544,3 +564,112 @@ test('the listing read and the catalog agree on every module', async () => {
544
564
  assert.equal(r.body.listed, shown.has(key), key);
545
565
  }
546
566
  });
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // the cap: at most ten per approved sub-category (task 1003809, ADR 0342 D2/D3)
570
+ // ---------------------------------------------------------------------------
571
+
572
+ const live = (parent, sub, label) => ({ parent, parent_title: parent, sub, label, status: 'approved', live: true });
573
+
574
+ test('the cap is ten (owner decision, 2026-09-10)', () => {
575
+ assert.equal(CAP_PER_SUBCATEGORY, 10);
576
+ });
577
+
578
+ test('applyCap: ten per approved sub-category, counted in ranking order; a proposed one or none is never capped', () => {
579
+ const ranked = Array.from({ length: 12 }, (_, i) => ({ key: `s${String(i).padStart(2, '0')}` }))
580
+ .concat([{ key: 'other' }, { key: 'proposed' }, { key: 'loose' }]);
581
+ const cats = new Map(ranked.slice(0, 12).map((e) => [e.key, live('money', 'stripe', 'Stripe payments')]));
582
+ cats.set('other', live('money', 'invoices', 'Invoices'));
583
+ cats.set('proposed', { parent: 'money', sub: 'stripe', label: 'Stripe', status: 'proposed', live: false });
584
+ const { kept, held } = applyCap(ranked, cats);
585
+ assert.deepEqual(kept.map((e) => e.key), [...ranked.slice(0, 10).map((e) => e.key), 'other', 'proposed', 'loose']);
586
+ assert.deepEqual(held.get('s10'), { rank: 11, cap: 10, label: 'Stripe payments' });
587
+ assert.deepEqual(held.get('s11'), { rank: 12, cap: 10, label: 'Stripe payments' });
588
+ assert.equal(held.size, 2);
589
+ });
590
+
591
+ test('applyCap: the same sub key under two parents is two comparable sets', () => {
592
+ const ranked = Array.from({ length: 11 }, (_, i) => ({ key: `a${i}` })).concat(Array.from({ length: 2 }, (_, i) => ({ key: `b${i}` })));
593
+ const cats = new Map(ranked.map((e) => [e.key, e.key.startsWith('a') ? live('money', 'general', 'General') : live('work', 'general', 'General')]));
594
+ const { held } = applyCap(ranked, cats);
595
+ assert.deepEqual([...held.keys()], ['a10']);
596
+ });
597
+
598
+ // The owner's example, verbatim: fifteen Stripe modules all scoring above 94% still
599
+ // show only the top ten.
600
+ function fifteenStripeModules() {
601
+ state.versionReads = [];
602
+ state.modules = {}; state.versions = []; state.scores = []; state.signals = []; state.prices = []; state.categories = {};
603
+ for (let i = 0; i < 15; i++) {
604
+ const key = `stripe-${String(i).padStart(2, '0')}`;
605
+ state.modules[key] = { status: 'listed', title: key };
606
+ state.versions.push({ id: 100 + i, module_key: key, version: '1.0.0', published_at: 't' });
607
+ state.scores.push({ id: 100 + i, version_id: 100 + i, computed_at: 1, ...score({ overall: 99 - i }) });
608
+ state.categories[key] = { parent: 'money', sub_key: 'stripe-payments', label: 'Stripe payments', status: 'approved' };
609
+ // The lowest scorers are best on Tests, so a sort by Tests would put them first.
610
+ state.signals.push({ id: 100 + i, version_id: 100 + i, part: 'tests', outcome: 'scored', score: 50 + i, sample_size: 10, measured_at: 1 });
611
+ }
612
+ state.modules.waiting = { status: 'listed', title: 'Waiting' };
613
+ state.versions.push({ id: 200, module_key: 'waiting', version: '1.0.0', published_at: 't' });
614
+ state.scores.push({ id: 200, version_id: 200, computed_at: 1, ...score({ overall: 60 }) });
615
+ state.categories.waiting = { parent: 'money', sub_key: 'stripe-billing', label: 'Stripe billing', status: 'proposed' };
616
+ }
617
+
618
+ test('GET /store/modules: fifteen Stripe modules above 94 show only the top ten; a proposed sub-category is not capped', async () => {
619
+ fifteenStripeModules();
620
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
621
+ const res = await invoke(handle, { query: {} });
622
+ assert.equal(res.body.cap, 10);
623
+ const stripe = res.body.listed.filter((m) => m.category && m.category.sub === 'stripe-payments');
624
+ assert.deepEqual(stripe.map((m) => m.overall), [99, 98, 97, 96, 95, 94, 93, 92, 91, 90]);
625
+ assert.deepEqual(stripe[0].category, { parent: 'money', parent_title: 'Money', sub: 'stripe-payments', label: 'Stripe payments' });
626
+ const waiting = res.body.listed.find((m) => m.key === 'waiting');
627
+ assert.ok(waiting, 'a module in a proposed sub-category is listed under its parent');
628
+ assert.deepEqual(waiting.category, { parent: 'money', parent_title: 'Money', sub: null, label: null }, 'a proposed sub-category is not shown');
629
+ });
630
+
631
+ test('GET /store/modules: a sort or a maximum price reorders or narrows the ten, never lets the eleventh in', async () => {
632
+ fifteenStripeModules();
633
+ state.prices = [{ module_key: 'stripe-00', price_cents: 5000, price_credits: null, set_at: 1 }];
634
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
635
+ const sorted = await invoke(handle, { query: { sort: 'tests' } });
636
+ const keys = (b) => b.listed.filter((m) => m.key.startsWith('stripe-')).map((m) => m.key);
637
+ assert.deepEqual(new Set(keys(sorted.body)), new Set(Array.from({ length: 10 }, (_, i) => `stripe-0${i}`)));
638
+ assert.equal(keys(sorted.body)[0], 'stripe-09', 'the best on Tests among the ten leads');
639
+ const cheap = await invoke(handle, { query: { max_cents: '1000' } });
640
+ assert.equal(keys(cheap.body).length, 9, 'the priced one drops out and nobody takes its slot');
641
+ assert.ok(!keys(cheap.body).includes('stripe-10'));
642
+ state.prices = [];
643
+ });
644
+
645
+ test('GET /store/modules/:key/listing: the eleventh is told it is past the cap, not below the floor', async () => {
646
+ fifteenStripeModules();
647
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules/:key/listing');
648
+ const eleventh = await invoke(handle, { params: { key: 'stripe-10' } });
649
+ assert.equal(eleventh.body.listed, false);
650
+ assert.equal(eleventh.body.reason, 'over_cap');
651
+ assert.equal(eleventh.body.overall, 89);
652
+ assert.match(eleventh.body.why, /scored 89 out of 100, above the floor of 50, but it ranks 11 in Stripe payments/);
653
+ assert.match(eleventh.body.why, /top 10 in each sub-category/);
654
+ assert.ok(eleventh.body.next_step);
655
+ // One module's check reads only its own sub-category, never the whole store.
656
+ const scoped = state.versionReads.filter((r) => r);
657
+ assert.ok(scoped.length, 'the cap check reads a scoped set');
658
+ for (const r of scoped) assert.ok(!r.includes('waiting'), 'a module outside the sub-category is not read');
659
+ assert.equal(state.versionReads.filter((r) => r === null).length, 0, 'the whole store is never read for one module');
660
+ const tenth = await invoke(handle, { params: { key: 'stripe-09' } });
661
+ assert.equal(tenth.body.listed, true);
662
+ assert.equal(tenth.body.reason, 'at_or_above_floor');
663
+ });
664
+
665
+ test('the cap: the listing read and the catalog agree on every module', async () => {
666
+ fifteenStripeModules();
667
+ const catalog = (await invoke(handlerFor(buildModulesRouter(), 'get', '/store/modules').handle, { query: {} })).body;
668
+ const shown = new Set([...catalog.listed, ...catalog.new].map((m) => m.key));
669
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules/:key/listing');
670
+ for (const key of Object.keys(state.modules)) {
671
+ const r = await invoke(handle, { params: { key } });
672
+ assert.equal(r.body.listed, shown.has(key), key);
673
+ }
674
+ state.categories = {}; state.signals = [];
675
+ });
@@ -0,0 +1,364 @@
1
+ // tests/module_store_subcategories.mjs — the sub-category approval queue (task
2
+ // 1004355, ADR 0342 D3): a publish proposes a sub-category the store has not seen,
3
+ // a Metic+ person approves (optionally renamed) or merges it, a merge moves its
4
+ // modules, and near-duplicate names sit beside each proposal. DB-free: an in-memory
5
+ // fake of the two core_278 tables, matched by SQL shape.
6
+ //
7
+ // Run: node tests/module_store_subcategories.mjs
8
+
9
+ import { test } from 'node:test';
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+
16
+ // The routes reach the database through pool.js; point it at whichever fake the
17
+ // route test below installs (the module_store_reassess.mjs harness).
18
+ let routeDb = null;
19
+ const poolPath = require.resolve('../src/bongos/pool.js');
20
+ const poolProxy = { query: (...a) => routeDb.query(...a), connect: (...a) => routeDb.connect(...a) };
21
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: poolProxy } };
22
+ const {
23
+ isNear, recordPublishCategory, listSubcategories, reviewQueue, readModuleCategories,
24
+ approveSubcategory, mergeSubcategory,
25
+ } = require('../src/bongos/module-subcategories.js');
26
+ const subs = { isNear, recordPublishCategory, listSubcategories, reviewQueue, readModuleCategories, approveSubcategory, mergeSubcategory };
27
+ const { checkRouteRanks } = require('../src/bongos/route-rank-check.js');
28
+ const catalog = require('../modules/government/catalog.js');
29
+
30
+ // ---- an in-memory store_subcategories + store_module_categories ----------------
31
+
32
+ function fakeDb() {
33
+ const state = { subs: [], cats: new Map(), nextId: 1, log: [] };
34
+ const query = async (sql, p = []) => {
35
+ const q = sql.replace(/\s+/g, ' ').trim();
36
+ state.log.push(q);
37
+ if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) return { rows: [] };
38
+ if (q.startsWith('INSERT INTO store_subcategories')) {
39
+ if (!state.subs.some((s) => s.parent === p[0] && s.sub_key === p[1])) {
40
+ state.subs.push({ id: String(state.nextId++), parent: p[0], sub_key: p[1], label: p[2], status: 'proposed', proposed_by: p[3], decided_by: null, decided_at: null, merged_into: null, created_at: new Date(state.nextId) });
41
+ }
42
+ return { rows: [] };
43
+ }
44
+ if (/FROM store_subcategories WHERE parent = \$1 AND sub_key = \$2/.test(q)) {
45
+ return { rows: state.subs.filter((s) => s.parent === p[0] && s.sub_key === p[1]).map((s) => ({ ...s })) };
46
+ }
47
+ if (/FROM store_subcategories WHERE id = ANY\(\$1\) ORDER BY id FOR UPDATE/.test(q)) {
48
+ const ids = p[0].map((x) => String(BigInt(x)));
49
+ return { rows: state.subs.filter((s) => ids.includes(s.id)).map((s) => ({ ...s })) };
50
+ }
51
+ if (/FROM store_subcategories WHERE id = \$1/.test(q)) return { rows: state.subs.filter((s) => s.id === String(p[0])).map((s) => ({ ...s })) };
52
+ if (/FROM store_subcategories WHERE status = 'approved'/.test(q)) return { rows: state.subs.filter((s) => s.status === 'approved') };
53
+ if (/FROM store_subcategories WHERE status <> 'merged'/.test(q)) return { rows: state.subs.filter((s) => s.status !== 'merged') };
54
+ if (q.startsWith('INSERT INTO store_module_categories')) {
55
+ state.cats.set(p[0], { module_key: p[0], parent: p[1], subcategory_id: p[2] == null ? null : String(p[2]) });
56
+ return { rows: [] };
57
+ }
58
+ if (/FROM store_module_categories WHERE subcategory_id = ANY/.test(q)) {
59
+ const ids = p[0].map(String);
60
+ return { rows: [...state.cats.values()].filter((c) => ids.includes(c.subcategory_id)) };
61
+ }
62
+ if (/FROM store_module_categories c LEFT JOIN/.test(q)) {
63
+ return { rows: [...state.cats.values()].filter((c) => p[0].includes(c.module_key)).map((c) => {
64
+ const s = state.subs.find((x) => x.id === c.subcategory_id);
65
+ return { module_key: c.module_key, parent: c.parent, sub_key: s ? s.sub_key : null, label: s ? s.label : null, status: s ? s.status : null };
66
+ }) };
67
+ }
68
+ if (q.startsWith("UPDATE store_subcategories SET status = 'approved'")) {
69
+ const s = state.subs.find((x) => x.id === String(p[0]));
70
+ Object.assign(s, { status: 'approved', label: p[1], sub_key: p[2], decided_by: p[3], decided_at: new Date() });
71
+ return { rows: [s] };
72
+ }
73
+ if (q.startsWith("UPDATE store_subcategories SET status = 'merged'")) {
74
+ const s = state.subs.find((x) => x.id === String(p[0]));
75
+ Object.assign(s, { status: 'merged', merged_into: String(p[1]), decided_by: p[2], decided_at: new Date() });
76
+ return { rows: [] };
77
+ }
78
+ if (q.startsWith('UPDATE store_subcategories SET merged_into')) {
79
+ for (const s of state.subs) if (s.merged_into === String(p[0])) s.merged_into = String(p[1]);
80
+ return { rows: [] };
81
+ }
82
+ if (q.startsWith('UPDATE store_module_categories')) {
83
+ const moved = [];
84
+ for (const c of state.cats.values()) {
85
+ if (c.subcategory_id === String(p[0])) { c.subcategory_id = String(p[1]); c.parent = p[2]; moved.push({ module_key: c.module_key }); }
86
+ }
87
+ return { rows: moved };
88
+ }
89
+ throw new Error(`fake db: unexpected SQL: ${q}`);
90
+ };
91
+ const client = { query, release() {} };
92
+ return { state, query, connect: async () => client, client };
93
+ }
94
+
95
+ const publish = (db, key, category, builderId = '7') => subs.recordPublishCategory(db.client, { key, category, builderId });
96
+
97
+ // ---- publish proposes ------------------------------------------------------------
98
+
99
+ test('publish: an unseen sub-category is proposed, not live, labelled from its key', async () => {
100
+ const db = fakeDb();
101
+ const out = await publish(db, 'pay-a', { parent: 'money', sub: 'stripe-payments' });
102
+ assert.equal(out.status, 'proposed');
103
+ assert.equal(out.live, false);
104
+ assert.equal(out.label, 'Stripe payments');
105
+ assert.equal(db.state.subs.length, 1);
106
+ assert.equal(db.state.cats.get('pay-a').subcategory_id, db.state.subs[0].id);
107
+ });
108
+
109
+ test('publish: a second module naming the same key joins the one proposal', async () => {
110
+ const db = fakeDb();
111
+ await publish(db, 'pay-a', { parent: 'money', sub: 'stripe' });
112
+ await publish(db, 'pay-b', { parent: 'money', sub: 'stripe' }, '8');
113
+ assert.equal(db.state.subs.length, 1);
114
+ const { proposals } = await subs.reviewQueue({ db });
115
+ assert.deepEqual(proposals[0].modules, ['pay-a', 'pay-b']);
116
+ });
117
+
118
+ test('publish: a parent with no sub sits under the parent only; no category leaves nothing written', async () => {
119
+ const db = fakeDb();
120
+ const out = await publish(db, 'chat', { parent: 'communication' });
121
+ assert.deepEqual(out, { parent: 'communication', sub: null, status: null, live: false });
122
+ assert.equal(db.state.cats.get('chat').subcategory_id, null);
123
+ assert.equal(await publish(db, 'other', undefined), null);
124
+ assert.equal(db.state.cats.has('other'), false);
125
+ });
126
+
127
+ test('publish: naming an approved sub-category is live at once', async () => {
128
+ const db = fakeDb();
129
+ await publish(db, 'pay-a', { parent: 'money', sub: 'stripe' });
130
+ await subs.approveSubcategory(db.state.subs[0].id, { builderId: '1' }, { db });
131
+ const out = await publish(db, 'pay-b', { parent: 'money', sub: 'stripe' });
132
+ assert.equal(out.live, true);
133
+ assert.equal(out.status, 'approved');
134
+ });
135
+
136
+ // ---- approve / rename ------------------------------------------------------------
137
+
138
+ test('approve: makes a proposal live and records who decided', async () => {
139
+ const db = fakeDb();
140
+ await publish(db, 'pay-a', { parent: 'money', sub: 'stripe' });
141
+ const r = await subs.approveSubcategory(db.state.subs[0].id, { builderId: '1' }, { db });
142
+ assert.equal(r.ok, true);
143
+ assert.equal(r.renamed, false);
144
+ assert.equal(r.subcategory.status, 'approved');
145
+ assert.equal(r.subcategory.decided_by, '1');
146
+ const cats = await subs.readModuleCategories(['pay-a'], { db });
147
+ assert.equal(cats.get('pay-a').live, true);
148
+ const listed = await subs.listSubcategories({ db });
149
+ assert.equal(listed.parents.length, 8);
150
+ assert.deepEqual(listed.parents.find((x) => x.parent === 'money').subcategories.map((s) => s.sub_key), ['stripe']);
151
+ });
152
+
153
+ test('approve: a rename takes a new label and derives its key; the module stays put', async () => {
154
+ const db = fakeDb();
155
+ await publish(db, 'pay-a', { parent: 'money', sub: 'payments-stripe' });
156
+ const r = await subs.approveSubcategory(db.state.subs[0].id, { label: 'Stripe payments', builderId: '1' }, { db });
157
+ assert.equal(r.renamed, true);
158
+ assert.equal(r.subcategory.sub_key, 'stripe-payments');
159
+ assert.equal((await subs.readModuleCategories(['pay-a'], { db })).get('pay-a').sub, 'stripe-payments');
160
+ });
161
+
162
+ test('approve: refuses a rename onto an existing key (that is a merge), and a decided row', async () => {
163
+ const db = fakeDb();
164
+ await publish(db, 'pay-a', { parent: 'money', sub: 'stripe' });
165
+ await publish(db, 'pay-b', { parent: 'money', sub: 'stripe-pay' });
166
+ const [a, b] = db.state.subs;
167
+ await subs.approveSubcategory(a.id, { builderId: '1' }, { db });
168
+ const clash = await subs.approveSubcategory(b.id, { subKey: 'stripe', builderId: '1' }, { db });
169
+ assert.equal(clash.code, 'subcategory_exists');
170
+ assert.match(clash.message, /merge/);
171
+ assert.equal((await subs.approveSubcategory(a.id, { builderId: '1' }, { db })).code, 'subcategory_decided');
172
+ assert.equal((await subs.approveSubcategory('99', { builderId: '1' }, { db })).code, 'subcategory_not_found');
173
+ assert.equal((await subs.approveSubcategory(b.id, { subKey: 'Bad Key', builderId: '1' }, { db })).code, 'bad_sub_key');
174
+ });
175
+
176
+ // ---- merge -----------------------------------------------------------------------
177
+
178
+ test('merge: moves every module of the merged name to the target, parent included', async () => {
179
+ const db = fakeDb();
180
+ await publish(db, 'pay-a', { parent: 'money', sub: 'stripe-payments' });
181
+ await publish(db, 'pay-b', { parent: 'money', sub: 'stripe' });
182
+ await publish(db, 'pay-c', { parent: 'work', sub: 'stripe' });
183
+ const [target, money, work] = db.state.subs;
184
+ await subs.approveSubcategory(target.id, { builderId: '1' }, { db });
185
+ const r1 = await subs.mergeSubcategory(money.id, { into: target.id, builderId: '1' }, { db });
186
+ assert.deepEqual(r1.moved, ['pay-b']);
187
+ const r2 = await subs.mergeSubcategory(work.id, { into: target.id, builderId: '1' }, { db });
188
+ assert.deepEqual(r2.moved, ['pay-c']);
189
+ const cats = await subs.readModuleCategories(['pay-b', 'pay-c'], { db });
190
+ assert.equal(cats.get('pay-b').sub, 'stripe-payments');
191
+ assert.equal(cats.get('pay-c').parent, 'money');
192
+ assert.equal(cats.get('pay-c').live, true);
193
+ });
194
+
195
+ test('merge: a later publish naming the merged key lands on the target, not a new proposal', async () => {
196
+ const db = fakeDb();
197
+ await publish(db, 'pay-a', { parent: 'money', sub: 'stripe-payments' });
198
+ await publish(db, 'pay-b', { parent: 'money', sub: 'stripe' });
199
+ const [target, dup] = db.state.subs;
200
+ await subs.approveSubcategory(target.id, { builderId: '1' }, { db });
201
+ await subs.mergeSubcategory(dup.id, { into: target.id, builderId: '1' }, { db });
202
+ const out = await publish(db, 'pay-d', { parent: 'money', sub: 'stripe' });
203
+ assert.equal(out.sub, 'stripe-payments');
204
+ assert.equal(out.live, true);
205
+ assert.equal(out.named, 'stripe');
206
+ assert.equal(db.state.subs.length, 2);
207
+ });
208
+
209
+ test('merge: an approved duplicate can be folded too, and earlier merges into it are re-pointed', async () => {
210
+ const db = fakeDb();
211
+ await publish(db, 'a', { parent: 'money', sub: 'stripe' });
212
+ await publish(db, 'b', { parent: 'money', sub: 'stripe-pay' });
213
+ await publish(db, 'c', { parent: 'money', sub: 'stripe-payments' });
214
+ const [s1, s2, s3] = db.state.subs;
215
+ await subs.approveSubcategory(s1.id, { builderId: '1' }, { db });
216
+ await subs.mergeSubcategory(s2.id, { into: s1.id, builderId: '1' }, { db });
217
+ await subs.approveSubcategory(s3.id, { builderId: '1' }, { db });
218
+ const r = await subs.mergeSubcategory(s1.id, { into: s3.id, builderId: '1' }, { db });
219
+ assert.deepEqual(r.moved, ['a', 'b']);
220
+ assert.equal(db.state.subs.find((s) => s.id === s2.id).merged_into, s3.id);
221
+ });
222
+
223
+ test('merge: refuses self, an unapproved target, a missing row and an already-merged one', async () => {
224
+ const db = fakeDb();
225
+ await publish(db, 'a', { parent: 'money', sub: 'stripe' });
226
+ await publish(db, 'b', { parent: 'money', sub: 'paypal' });
227
+ const [s1, s2] = db.state.subs;
228
+ assert.equal((await subs.mergeSubcategory(s1.id, { into: s1.id, builderId: '1' }, { db })).code, 'merge_into_self');
229
+ assert.equal((await subs.mergeSubcategory(s1.id, { into: s2.id, builderId: '1' }, { db })).code, 'merge_target_not_approved');
230
+ assert.equal((await subs.mergeSubcategory('99', { into: s2.id, builderId: '1' }, { db })).code, 'subcategory_not_found');
231
+ await subs.approveSubcategory(s2.id, { builderId: '1' }, { db });
232
+ await subs.mergeSubcategory(s1.id, { into: s2.id, builderId: '1' }, { db });
233
+ assert.equal((await subs.mergeSubcategory(s1.id, { into: s2.id, builderId: '1' }, { db })).code, 'subcategory_decided');
234
+ assert.ok(db.state.log.includes('ROLLBACK'));
235
+ });
236
+
237
+ // ---- near-duplicates ---------------------------------------------------------------
238
+
239
+ test('near: "Stripe", "Stripe payments" and "Payments (Stripe)" sit beside each other; unrelated names do not', () => {
240
+ assert.ok(subs.isNear('Stripe', 'Stripe payments'));
241
+ assert.ok(subs.isNear('Stripe payments', 'Payments (Stripe)'));
242
+ assert.ok(subs.isNear('stripe-payment', 'Stripe payments'));
243
+ assert.ok(subs.isNear('stirpe', 'stripe'));
244
+ assert.ok(!subs.isNear('Stripe', 'Discord bots'));
245
+ assert.ok(!subs.isNear('Weather', 'Whether forecasts'));
246
+ });
247
+
248
+ test('queue: each proposal carries its near-duplicates across parents, never itself or a merged row', async () => {
249
+ const db = fakeDb();
250
+ await publish(db, 'a', { parent: 'money', sub: 'stripe' });
251
+ await publish(db, 'b', { parent: 'money', sub: 'stripe-payments' });
252
+ await publish(db, 'c', { parent: 'work', sub: 'payments-stripe' });
253
+ await publish(db, 'd', { parent: 'communication', sub: 'discord-bots' });
254
+ const { proposals } = await subs.reviewQueue({ db });
255
+ const byKey = Object.fromEntries(proposals.map((p) => [`${p.parent}/${p.sub_key}`, p]));
256
+ assert.deepEqual(byKey['money/stripe'].near.map((n) => n.sub_key).sort(), ['payments-stripe', 'stripe-payments']);
257
+ assert.deepEqual(byKey['communication/discord-bots'].near, []);
258
+ assert.equal(byKey['money/stripe'].parent_title, 'Money');
259
+ });
260
+
261
+ // ---- authority ---------------------------------------------------------------------
262
+
263
+ test('authority: the review routes are pinned to their own Metic+ atom, granted to metic and archon', () => {
264
+ const entry = catalog.byKey('module.subcategory.review');
265
+ assert.ok(entry, 'catalog defines module.subcategory.review');
266
+ assert.equal(entry.floor, 'metic');
267
+ assert.equal(entry.system, false);
268
+ const sql = fs.readFileSync(new URL('../modules/government/migrations/government_034_module_subcategory_review.sql', import.meta.url), 'utf8');
269
+ for (const rank of ['metic', 'archon']) assert.match(sql, new RegExp(`SELECT '${rank}', unnest\\(ARRAY\\[\\s*'module\\.subcategory\\.review'`));
270
+ const res = checkRouteRanks();
271
+ assert.equal(res.ok, true);
272
+ assert.deepEqual((res.findings || []).filter((f) => /subcategor/.test(JSON.stringify(f))), []);
273
+ });
274
+
275
+ test('merge: both rows are locked in one statement, in id order (no deadlock between opposite merges)', async () => {
276
+ const db = fakeDb();
277
+ await publish(db, 'a', { parent: 'money', sub: 'stripe' });
278
+ await publish(db, 'b', { parent: 'money', sub: 'paypal' });
279
+ const [s1, s2] = db.state.subs;
280
+ await subs.approveSubcategory(s1.id, { builderId: '1' }, { db });
281
+ db.state.log.length = 0;
282
+ await subs.mergeSubcategory(s2.id, { into: s1.id, builderId: '1' }, { db });
283
+ const locks = db.state.log.filter((q) => /FOR UPDATE/.test(q));
284
+ assert.equal(locks.length, 1);
285
+ assert.match(locks[0], /WHERE id = ANY\(\$1\) ORDER BY id FOR UPDATE/);
286
+ });
287
+
288
+ test('queue: bounded to the oldest hundred proposals, and says how many wait', async () => {
289
+ const db = fakeDb();
290
+ for (let i = 0; i < 105; i++) await publish(db, `m${i}`, { parent: 'work', sub: `kind-${i}` });
291
+ const q = await subs.reviewQueue({ db });
292
+ assert.equal(q.proposals.length, 100);
293
+ assert.equal(q.total, 105);
294
+ assert.equal(q.more, true);
295
+ assert.equal(q.proposals[0].sub_key, 'kind-0');
296
+ });
297
+
298
+ // ---- the HTTP routes, through the real router ------------------------------------
299
+
300
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
301
+ const router = buildModulesRouter();
302
+ function route(method, p) {
303
+ const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
304
+ assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
305
+ return layer.route.stack;
306
+ }
307
+ async function call(method, p, { params = {}, body = {} } = {}) {
308
+ const stack = route(method, p);
309
+ const res = {
310
+ statusCode: 200, body: undefined, headers: {},
311
+ json(b) { this.body = b; return this; },
312
+ status(c) { this.statusCode = c; return this; },
313
+ set(k, v) { this.headers[k] = v; return this; },
314
+ fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
315
+ };
316
+ let err = null;
317
+ await stack[stack.length - 1].handle({ params, body, query: {}, builder: { id: 3 } }, res, (e) => { err = e; });
318
+ if (err) throw err;
319
+ return res;
320
+ }
321
+
322
+ test('routes: the three review routes sit behind requireBuilder + the module.subcategory.review atom; the list behind requireBuilder only', () => {
323
+ const src = fs.readFileSync(new URL('../src/bongos/routes/modules.js', import.meta.url), 'utf8');
324
+ for (const decl of [
325
+ "router.get('/store/subcategories/queue'",
326
+ "router.post('/store/subcategories/:id/approve'",
327
+ "router.post('/store/subcategories/:id/merge'",
328
+ ]) {
329
+ assert.ok(src.includes(`${decl}, auth.requireBuilder, auth.requirePermission('module.subcategory.review')`), decl);
330
+ }
331
+ assert.equal(route('get', '/store/subcategories/queue').length, 3);
332
+ assert.equal(route('get', '/store/subcategories').length, 2);
333
+ });
334
+
335
+ test('routes: list, queue, approve and merge end to end, refusals mapped to their status', async () => {
336
+ routeDb = fakeDb();
337
+ await publish(routeDb, 'pay-a', { parent: 'money', sub: 'stripe-payments' });
338
+ await publish(routeDb, 'pay-b', { parent: 'money', sub: 'stripe' });
339
+ const [target, dup] = routeDb.state.subs;
340
+
341
+ const queue = await call('get', '/store/subcategories/queue');
342
+ assert.equal(queue.statusCode, 200);
343
+ assert.equal(queue.body.proposals.length, 2);
344
+ assert.equal(queue.body.proposals[0].near[0].sub_key, 'stripe');
345
+
346
+ assert.equal((await call('post', '/store/subcategories/:id/approve', { params: { id: 'x1' } })).statusCode, 400);
347
+ assert.equal((await call('post', '/store/subcategories/:id/approve', { params: { id: target.id }, body: { label: 5 } })).statusCode, 400);
348
+ assert.equal((await call('post', '/store/subcategories/:id/approve', { params: { id: '999' } })).statusCode, 404);
349
+ const ok = await call('post', '/store/subcategories/:id/approve', { params: { id: target.id }, body: { label: 'Stripe payments' } });
350
+ assert.equal(ok.statusCode, 200);
351
+ assert.equal(ok.body.subcategory.status, 'approved');
352
+ assert.equal((await call('post', '/store/subcategories/:id/approve', { params: { id: target.id } })).statusCode, 409);
353
+
354
+ const listed = await call('get', '/store/subcategories');
355
+ assert.equal(listed.statusCode, 200);
356
+ assert.deepEqual(listed.body.parents.find((x) => x.parent === 'money').subcategories.map((x) => x.sub_key), ['stripe-payments']);
357
+
358
+ assert.equal((await call('post', '/store/subcategories/:id/merge', { params: { id: dup.id }, body: {} })).statusCode, 400);
359
+ assert.equal((await call('post', '/store/subcategories/:id/merge', { params: { id: dup.id }, body: { into: '0' + dup.id } })).statusCode, 400);
360
+ const merged = await call('post', '/store/subcategories/:id/merge', { params: { id: dup.id }, body: { into: Number(target.id) } });
361
+ assert.equal(merged.statusCode, 200);
362
+ assert.deepEqual(merged.body.moved, ['pay-b']);
363
+ assert.equal((await call('post', '/store/subcategories/:id/merge', { params: { id: dup.id }, body: { into: target.id } })).statusCode, 409);
364
+ });