@bongos/core 1.23.1 → 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.
- package/.bongos-core.json +49 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0342-module-categories-are-eight-parents-with-approved-sub-categories.md +4 -2
- package/docs/api/openapi.json +210 -4
- package/docs/api-reference.md +6 -2
- package/docs/architecture.md +11 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +1 -0
- package/migrations/core_280_store_subcategories.sql +58 -0
- package/modules/government/catalog.js +3 -0
- package/modules/government/migrations/government_034_module_subcategory_review.sql +35 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/module-store.js +8 -3
- package/src/bongos/module-subcategories.js +316 -0
- package/src/bongos/route-rank-check.js +5 -0
- package/src/bongos/routes/modules.js +55 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +4 -3
- package/tests/deploy_divergence_line.mjs +4 -1
- package/tests/module_store_instance_root.mjs +2 -1
- package/tests/module_store_subcategories.mjs +364 -0
|
@@ -55,8 +55,9 @@
|
|
|
55
55
|
// // never a silent pass.
|
|
56
56
|
// "category": { // OPTIONAL. Where the module is browsed and ranked (ADR 0342).
|
|
57
57
|
// "parent": "money", // REQUIRED in-block: one of the eight CATEGORY_PARENTS keys.
|
|
58
|
-
// "sub": "stripe-payments" // OPTIONAL.
|
|
59
|
-
// }, //
|
|
58
|
+
// "sub": "stripe-payments" // OPTIONAL. A sub-category key — the comparable set the store's
|
|
59
|
+
// }, // cap of ten counts within. One the store has not approved is
|
|
60
|
+
// // PROPOSED at publish and is not live until Metic+ approves it.
|
|
60
61
|
// "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
|
|
61
62
|
// "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
|
|
62
63
|
// }, // never graded and never required — the file is the how-to.
|
|
@@ -354,7 +355,7 @@ function validateManifest(obj) {
|
|
|
354
355
|
if (!isStr(c.parent)) E(`category.parent: required — one of ${parents.join(', ')}`);
|
|
355
356
|
else if (!parents.includes(c.parent)) E(`category.parent: "${c.parent}" is not one of ${parents.join(', ')} (ADR 0342 D1)`);
|
|
356
357
|
if ('sub' in c && (!isStr(c.sub) || !KEY_RE.test(c.sub))) {
|
|
357
|
-
E('category.sub: must be a sub-category key (lowercase kebab-case) when present —
|
|
358
|
+
E('category.sub: must be a sub-category key (lowercase kebab-case) when present — a new one is proposed at publish');
|
|
358
359
|
}
|
|
359
360
|
}
|
|
360
361
|
}
|
|
@@ -357,7 +357,10 @@ test('liveIncludes reads inclusion by release when the live commit is not in thi
|
|
|
357
357
|
assert.equal(liveIncludes({ coreVersion: olderV }, newer, 'HEAD'), false, 'an older release does not');
|
|
358
358
|
assert.equal(liveIncludes({ coreVersion: olderV }, older, 'HEAD'), true);
|
|
359
359
|
// A commit between the two bumps is carried by the NEWER one only.
|
|
360
|
-
|
|
360
|
+
// --ancestry-path: a commit on a side branch merged in between the two bumps is
|
|
361
|
+
// in the range but is not descended from the older bump, so the older release's
|
|
362
|
+
// position says nothing about it (a release commit merged in from main did this).
|
|
363
|
+
const between = gitOut(['rev-list', '--max-count=1', '--no-merges', '--ancestry-path', `${older}..${newer}~1`]);
|
|
361
364
|
if (between) {
|
|
362
365
|
assert.equal(liveIncludes({ coreVersion: olderV }, between, 'HEAD'), false, 'not yet: the older release predates it');
|
|
363
366
|
assert.equal(liveIncludes({ coreVersion: newerV }, between, 'HEAD'), true, 'the next bump carries it');
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// same bug in the task-visuals store.
|
|
7
7
|
//
|
|
8
8
|
// This builds a real consumer tree in a temp dir: a fake installed core package
|
|
9
|
-
// carrying the REAL src/bongos/module-store.js, with its
|
|
9
|
+
// carrying the REAL src/bongos/module-store.js, with its four core requires
|
|
10
10
|
// re-exporting the real files, then proves, through that installed copy:
|
|
11
11
|
// 1. the store is the instance's var/module-store, not inside the package;
|
|
12
12
|
// 2. a published tarball survives the package being deleted and reinstalled,
|
|
@@ -52,6 +52,7 @@ function installCore() {
|
|
|
52
52
|
fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'semver.js'), reexport(REAL('module-loader', 'semver.js')));
|
|
53
53
|
fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'manifest-schema.js'), reexport(REAL('module-loader', 'manifest-schema.js')));
|
|
54
54
|
fs.writeFileSync(path.join(CORE, 'src', 'instance-config.js'), reexport(REAL('instance-config.js')));
|
|
55
|
+
fs.writeFileSync(path.join(CORE, 'src', 'bongos', 'module-subcategories.js'), reexport(REAL('bongos', 'module-subcategories.js')));
|
|
55
56
|
}
|
|
56
57
|
// A fresh require of the INSTALLED copy (a restarted server).
|
|
57
58
|
function loadInstalled() {
|
|
@@ -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
|
+
});
|