@bongos/core 1.22.2 → 1.23.2

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.
Files changed (66) hide show
  1. package/.bongos-core.json +104 -64
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0342-module-categories-are-eight-parents-with-approved-sub-categories.md +4 -2
  8. package/docs/api/openapi.json +210 -4
  9. package/docs/api-reference.md +6 -2
  10. package/docs/architecture.md +11 -0
  11. package/docs/copy-inventory.md +1 -1
  12. package/docs/copy-registry.json +1 -1
  13. package/docs/module-api-changelog.md +8 -0
  14. package/docs/modules-contract.md +1 -0
  15. package/docs/page-readings.json +40 -40
  16. package/migrations/core_280_store_subcategories.sql +58 -0
  17. package/modules/government/catalog.js +3 -0
  18. package/modules/government/migrations/government_034_module_subcategory_review.sql +35 -0
  19. package/modules/hall-ui/public/deploy.js +3 -3
  20. package/modules/hall-ui/public/dom-utils.js +5 -1
  21. package/modules/hall-ui/public/genesis-home.js +1 -1
  22. package/modules/lifecycle/github-push.js +1 -2
  23. package/modules/npm-release/preview/supervisor.js +2 -1
  24. package/modules/platform-identity/project-stats.js +2 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +13 -0
  28. package/scripts/bongos/autobongos-run.js +2 -1
  29. package/scripts/bongos/autonomy-gauge.js +2 -1
  30. package/scripts/bongos/backfill-disciplines.js +1 -1
  31. package/scripts/bongos/backfill-sessions.js +1 -1
  32. package/scripts/bongos/backup.js +2 -1
  33. package/scripts/bongos/dev.js +1 -1
  34. package/scripts/bongos/do-api.js +2 -1
  35. package/scripts/bongos/fitness.js +5 -6
  36. package/scripts/bongos/local-preview.js +2 -1
  37. package/scripts/bongos/login.js +1 -1
  38. package/scripts/bongos/page-reader.js +2 -1
  39. package/scripts/bongos/probe-cache.js +2 -1
  40. package/scripts/bongos/provision-core-upgrade.js +2 -1
  41. package/scripts/bongos/provision.js +5 -4
  42. package/scripts/bongos/publish-integration-gate.js +2 -1
  43. package/scripts/bongos/render-api.js +2 -1
  44. package/scripts/bongos/sandbox-stage.js +2 -1
  45. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +1 -1
  46. package/scripts/bongos/seed-bv1-profiles-tasks.js +1 -1
  47. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +1 -1
  48. package/scripts/bongos/session-eval.js +2 -1
  49. package/scripts/bongos/setup.js +2 -1
  50. package/scripts/bongos/sleep-helper-guard.js +88 -0
  51. package/scripts/bongos/upgrade.js +2 -2
  52. package/scripts/gds/sleep-helper-guard.js +6 -0
  53. package/src/bongos/module-scope-map.js +1 -0
  54. package/src/bongos/module-store.js +8 -3
  55. package/src/bongos/module-subcategories.js +316 -0
  56. package/src/bongos/route-rank-check.js +5 -0
  57. package/src/bongos/routes/modules.js +55 -1
  58. package/src/module-api.js +6 -1
  59. package/src/module-loader/manifest-schema.js +4 -3
  60. package/src/sleep.js +24 -0
  61. package/tests/deploy_divergence_line.mjs +4 -1
  62. package/tests/deploy_page_projects.mjs +2 -1
  63. package/tests/module_api.mjs +1 -0
  64. package/tests/module_store_instance_root.mjs +2 -1
  65. package/tests/module_store_subcategories.mjs +364 -0
  66. package/tests/sleep_helper.mjs +99 -0
@@ -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
+ });
@@ -0,0 +1,99 @@
1
+ // tests/sleep_helper.mjs — ONE "wait N milliseconds" helper per side (task 1004893, goal 1000135).
2
+ //
3
+ // ~27 files each hand-rolled `new Promise((r) => setTimeout(r, ms))`. They were
4
+ // collapsed onto two: window.OTB.sleep (modules/hall-ui/public/dom-utils.js) in
5
+ // the browser and sleep (src/sleep.js, re-exported by the module doorway as
6
+ // api.sleep) on the server. The two blocking Atomics.wait sleeps
7
+ // (modules/lifecycle/merge-lock.js, scripts/bongos/rename-ledger-prose.js) are a
8
+ // different thing — they freeze the thread on purpose — and stay local. These
9
+ // tests pin:
10
+ // 1. the server sleep waits about as long as asked, and resolves to undefined;
11
+ // 2. the module doorway hands modules the very same function;
12
+ // 3. the browser twin exists on window.OTB and waits too;
13
+ // 4. the fitness check goes red on a new hand-rolled copy and stays green on
14
+ // the repo, the two shared helpers and a blocking sleep.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import { createRequire } from 'node:module';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
28
+
29
+ const { sleep } = require('../src/sleep.js');
30
+ const guard = require('../scripts/bongos/sleep-helper-guard.js');
31
+
32
+ test('server sleep waits roughly the time asked and resolves to undefined', async () => {
33
+ const t0 = Date.now();
34
+ const v = await sleep(30);
35
+ assert.equal(v, undefined);
36
+ assert.ok(Date.now() - t0 >= 25, 'waited at least ~30ms');
37
+ });
38
+
39
+ test('server sleep keeps setTimeout\'s forgiving inputs (null, a numeric string) instead of throwing', async () => {
40
+ // node:timers/promises throws ERR_INVALID_ARG_TYPE on these; the hand-rolled copies never did.
41
+ await sleep(null);
42
+ await sleep('1');
43
+ });
44
+
45
+ test('the module doorway exports the shared server sleep', () => {
46
+ const api = require('../src/module-api.js');
47
+ assert.equal(api.sleep, sleep);
48
+ });
49
+
50
+ test('the browser twin is window.OTB.sleep and waits', async () => {
51
+ const sandbox = { window: {}, document: { querySelector: () => null }, setTimeout, Promise };
52
+ vm.createContext(sandbox);
53
+ vm.runInContext(fs.readFileSync(path.join(PUB, 'dom-utils.js'), 'utf8'), sandbox, { filename: 'dom-utils.js' });
54
+ const OTB = sandbox.window.OTB;
55
+ assert.equal(typeof OTB.sleep, 'function');
56
+ const t0 = Date.now();
57
+ await OTB.sleep(20);
58
+ assert.ok(Date.now() - t0 >= 15);
59
+ });
60
+
61
+ test('fitness: the repo holds no hand-rolled sleep outside the two shared helpers', () => {
62
+ const r = guard.checkOneSleepHelper();
63
+ assert.equal(r.ok, true, r.violations.join('\n'));
64
+ });
65
+
66
+ test('fitness: finds every shape of a hand-rolled sleep', () => {
67
+ const shapes = [
68
+ 'const sleep = (ms) => new Promise((r) => setTimeout(r, ms));',
69
+ 'const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));',
70
+ ' await new Promise(resolve => setTimeout(resolve, 3_000));',
71
+ ' sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,',
72
+ 'await Promise.race([a, new Promise((r) => setTimeout(r, 2000))]);',
73
+ 'function sleep(ms) { return new Promise(function (res) { setTimeout(res, ms); }); }',
74
+ ];
75
+ for (const s of shapes) assert.deepEqual(guard.findHandRolledSleeps(s), [1], s);
76
+ });
77
+
78
+ test('fitness: a blocking Atomics.wait sleep and a timer that does real work are not flagged', () => {
79
+ assert.deepEqual(guard.findHandRolledSleeps('function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }'), []);
80
+ assert.deepEqual(guard.findHandRolledSleeps('new Promise((r) => setTimeout(() => r(compute()), 10))'), []);
81
+ assert.deepEqual(guard.findHandRolledSleeps('const t = setTimeout(resolve, ms); t.unref();'), []);
82
+ });
83
+
84
+ test('fitness: goes red on a planted copy, green once it is gone', () => {
85
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 't1004893-'));
86
+ try {
87
+ fs.mkdirSync(path.join(tmp, 'scripts', 'bongos'), { recursive: true });
88
+ const f = path.join(tmp, 'scripts', 'bongos', 'x.js');
89
+ fs.writeFileSync(f, "const sleep = (ms) => new Promise((r) => setTimeout(r, ms));\n");
90
+ let r = guard.checkOneSleepHelper({ root: tmp });
91
+ assert.equal(r.ok, false);
92
+ assert.match(r.violations[0], /scripts\/bongos\/x\.js:1/);
93
+ fs.writeFileSync(f, "const { sleep } = require('../../src/sleep');\n");
94
+ r = guard.checkOneSleepHelper({ root: tmp });
95
+ assert.equal(r.ok, true, r.violations.join('\n'));
96
+ } finally {
97
+ fs.rmSync(tmp, { recursive: true, force: true });
98
+ }
99
+ });