@bongos/core 1.21.156 → 1.21.158
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 +43 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +164 -5
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +28 -22
- package/docs/copy-registry.json +77 -23
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -1
- package/docs/page-readings.json +29 -24
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/modules.html +11 -0
- package/modules/hall-ui/public/modules.js +104 -0
- package/modules/hall-ui/records/catalog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module.js +3 -0
- package/src/bongos/module-store.js +15 -3
- package/src/bongos/module-testing.js +73 -0
- package/src/bongos/project-settings.js +3 -0
- package/src/bongos/routes/modules.js +46 -2
- package/src/module-api.js +1 -1
- package/tests/hall_catalog_world.mjs +18 -2
- package/tests/module_store_publish.mjs +32 -0
- package/tests/module_testing.mjs +171 -0
|
@@ -195,6 +195,7 @@ test('store: a key or version that could escape the store dir is refused before
|
|
|
195
195
|
// A fake pg pool: `state` is the registry, answers are computed from it.
|
|
196
196
|
function fakeDb(state = { modules: {}, versions: [] }) {
|
|
197
197
|
const log = [];
|
|
198
|
+
state.channels = state.channels || [];
|
|
198
199
|
const client = {
|
|
199
200
|
async query(sql, params = []) {
|
|
200
201
|
log.push(sql.trim().split(/\s+/).slice(0, 2).join(' '));
|
|
@@ -211,6 +212,10 @@ function fakeDb(state = { modules: {}, versions: [] }) {
|
|
|
211
212
|
const row = { id: state.versions.length + 1, module_key: params[0], version: params[1] };
|
|
212
213
|
state.versions.push(row); return { rows: [row] };
|
|
213
214
|
}
|
|
215
|
+
if (/^INSERT INTO store_module_version_channels/.test(sql)) {
|
|
216
|
+
state.channels.push({ version_id: params[0], channel: params[1], reason: params[2], set_by: params[3] });
|
|
217
|
+
return { rows: [] };
|
|
218
|
+
}
|
|
214
219
|
return { rows: [] };
|
|
215
220
|
},
|
|
216
221
|
release() {},
|
|
@@ -256,6 +261,33 @@ test('registry: a version is published once, and never older than the latest', a
|
|
|
256
261
|
assert.equal(ok.ok, true, 'numeric, not string, ordering: 1.10.0 > 1.2.0');
|
|
257
262
|
});
|
|
258
263
|
|
|
264
|
+
// Task 1003803, ADR 0344 D2: every new version, first release or update, lands on
|
|
265
|
+
// the tester channel. Publish takes no channel at all, so there is no flag to omit
|
|
266
|
+
// or misspell; reaching everyone is the promotion gate's later act (task 1003804).
|
|
267
|
+
test('channel: a first release lands on tester, recorded with who published it', async () => {
|
|
268
|
+
const db = fakeDb();
|
|
269
|
+
const r = await store.publishVersion(input(), { db: db.pool });
|
|
270
|
+
assert.equal(r.ok, true);
|
|
271
|
+
assert.equal(r.channel, 'tester');
|
|
272
|
+
assert.deepEqual(db.state.channels, [{ version_id: 1, channel: 'tester', reason: 'new_version', set_by: 7 }]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('channel: an update lands on tester too, never straight to general', async () => {
|
|
276
|
+
const state = { modules: { weather: { module_key: 'weather', author_id: 7, status: 'listed' } },
|
|
277
|
+
versions: [{ id: 1, module_key: 'weather', version: '1.1.0' }] };
|
|
278
|
+
const db = fakeDb(state);
|
|
279
|
+
const r = await store.publishVersion(input({ channel: 'general' }), { db: db.pool });
|
|
280
|
+
assert.equal(r.ok, true);
|
|
281
|
+
assert.equal(r.channel, 'tester', 'a channel named by the caller is not a way past testers');
|
|
282
|
+
assert.deepEqual(db.state.channels.map((c) => c.channel), ['tester']);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('channel: a refused publish records no channel', async () => {
|
|
286
|
+
const db = fakeDb({ modules: { weather: { module_key: 'weather', author_id: 7, status: 'listed' } }, versions: [] });
|
|
287
|
+
await store.publishVersion(input({ builderId: 8 }), { db: db.pool });
|
|
288
|
+
assert.deepEqual(db.state.channels, []);
|
|
289
|
+
});
|
|
290
|
+
|
|
259
291
|
test('registry: if putting the file in place fails, the row is rolled back', async () => {
|
|
260
292
|
const db = fakeDb();
|
|
261
293
|
await assert.rejects(
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/module_testing.mjs — a project elects to test new module versions first
|
|
2
|
+
// (task 1003802, ADR 0344 D1). Two halves, no port and no real DB:
|
|
3
|
+
// - the rule: module-testing.js over an in-memory project_settings, through
|
|
4
|
+
// its deps seam — never a tester by default, reversible, "general only";
|
|
5
|
+
// - the routes: the real handlers off the real router, with pool.js replaced
|
|
6
|
+
// in the require cache (the tests/module_store_listing.mjs harness).
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/module_testing.mjs
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
// ---- an in-memory project_settings, behind the real setSetting/getSetting SQL ----
|
|
17
|
+
const rows = new Map();
|
|
18
|
+
const pool = {
|
|
19
|
+
async query(sql, params = []) {
|
|
20
|
+
if (/FROM project_settings WHERE key = \$1/.test(sql)) {
|
|
21
|
+
const r = rows.get(params[0]);
|
|
22
|
+
return { rows: r ? [r] : [] };
|
|
23
|
+
}
|
|
24
|
+
if (/INSERT INTO project_settings/.test(sql)) {
|
|
25
|
+
const r = { key: params[0], value: params[1], updated_by: params[2], updated_at: 't' };
|
|
26
|
+
rows.set(params[0], r);
|
|
27
|
+
return { rows: [r] };
|
|
28
|
+
}
|
|
29
|
+
return { rows: [] };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
33
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
34
|
+
|
|
35
|
+
const { readTesting, setTester, setGeneralOnly, testsVersionsOf, parseGeneralOnly } = require('../src/bongos/module-testing.js');
|
|
36
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
37
|
+
const { listModules } = buildModulesRouter;
|
|
38
|
+
|
|
39
|
+
function handlerFor(router, method, p) {
|
|
40
|
+
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
41
|
+
assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
|
|
42
|
+
const stack = layer.route.stack;
|
|
43
|
+
return { handle: stack[stack.length - 1].handle, names: stack.map((s) => s.name) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function invoke(handle, req) {
|
|
47
|
+
const res = {
|
|
48
|
+
statusCode: 200, body: undefined, headers: {},
|
|
49
|
+
json(b) { this.body = b; return this; },
|
|
50
|
+
status(c) { this.statusCode = c; return this; },
|
|
51
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
52
|
+
// Both shapes res.fail is called with: (code, { status, message }) and the
|
|
53
|
+
// validator's (code, 400, details).
|
|
54
|
+
fail(code, opts) {
|
|
55
|
+
const o = typeof opts === 'number' ? { status: opts } : opts;
|
|
56
|
+
this.statusCode = o.status; this.body = { error: code, ...o }; return this;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
let nextErr = null;
|
|
60
|
+
await handle({ params: {}, body: {}, builder: { id: 7 }, ...req }, res, (err) => { nextErr = err; });
|
|
61
|
+
if (nextErr) throw nextErr;
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// the rule
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test('a project is never a tester by default', async () => {
|
|
70
|
+
rows.clear();
|
|
71
|
+
const t = await readTesting();
|
|
72
|
+
assert.deepEqual(t, { tester: false, general_only: [], updated_at: null, updated_by: null });
|
|
73
|
+
assert.equal(testsVersionsOf(t, 'discord'), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('opting in is an act, and it is reversible', async () => {
|
|
77
|
+
rows.clear();
|
|
78
|
+
const on = await setTester(true, 7);
|
|
79
|
+
assert.equal(on.tester, true);
|
|
80
|
+
assert.equal(on.updated_by, '7', 'who switched it is recorded');
|
|
81
|
+
assert.equal(testsVersionsOf(on, 'discord'), true);
|
|
82
|
+
const off = await setTester(false, 7);
|
|
83
|
+
assert.equal(off.tester, false);
|
|
84
|
+
assert.equal(testsVersionsOf(off, 'discord'), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a module marked general only keeps general versions while the rest test', async () => {
|
|
88
|
+
rows.clear();
|
|
89
|
+
await setTester(true, 7);
|
|
90
|
+
await setGeneralOnly('economy', true, 7);
|
|
91
|
+
const t = await setGeneralOnly('discord', true, 7);
|
|
92
|
+
assert.deepEqual(t.general_only, ['discord', 'economy']);
|
|
93
|
+
assert.equal(testsVersionsOf(t, 'economy'), false);
|
|
94
|
+
assert.equal(testsVersionsOf(t, 'memory'), true);
|
|
95
|
+
const back = await setGeneralOnly('economy', false, 7);
|
|
96
|
+
assert.deepEqual(back.general_only, ['discord']);
|
|
97
|
+
assert.equal(testsVersionsOf(back, 'economy'), true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a "general only" mark survives the switch going off and on', async () => {
|
|
101
|
+
rows.clear();
|
|
102
|
+
await setTester(true, 7);
|
|
103
|
+
await setGeneralOnly('economy', true, 7);
|
|
104
|
+
await setTester(false, 7);
|
|
105
|
+
const t = await setTester(true, 7);
|
|
106
|
+
assert.deepEqual(t.general_only, ['economy']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('an unreadable stored value reads as NOT a tester, and keeps the keys it can read', async () => {
|
|
110
|
+
rows.clear();
|
|
111
|
+
rows.set('modules.tester', { key: 'modules.tester', value: 'yes please', updated_by: null, updated_at: 't' });
|
|
112
|
+
assert.equal((await readTesting()).tester, false, 'only "on" is on');
|
|
113
|
+
assert.deepEqual(parseGeneralOnly('not json'), []);
|
|
114
|
+
assert.deepEqual(parseGeneralOnly('{"a":1}'), []);
|
|
115
|
+
assert.deepEqual(parseGeneralOnly('["economy", 4, "Bad Key", "economy", "discord"]'), ['discord', 'economy']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('setGeneralOnly refuses something that is not a module key', async () => {
|
|
119
|
+
await assert.rejects(() => setGeneralOnly('../etc', true, 7), /not a module key/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// the routes
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
test('GET /modules/testing: any signed-in builder can see the switch', async () => {
|
|
127
|
+
rows.clear();
|
|
128
|
+
const { handle, names } = handlerFor(buildModulesRouter(), 'get', '/modules/testing');
|
|
129
|
+
assert.equal(names.length, 2, `expected requireBuilder + handler, got ${names.join(', ')}`);
|
|
130
|
+
const res = await invoke(handle, {});
|
|
131
|
+
assert.equal(res.body.tester, false);
|
|
132
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('POST /modules/testing and /modules/:key/general-only take the module.enable permission', () => {
|
|
136
|
+
const router = buildModulesRouter();
|
|
137
|
+
for (const p of ['/modules/testing', '/modules/:key/general-only']) {
|
|
138
|
+
const { names } = handlerFor(router, 'post', p);
|
|
139
|
+
assert.equal(names.length, 3, `${p}: requireBuilder + requirePermission + handler, got ${names.join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
const src = require('node:fs').readFileSync(require.resolve('../src/bongos/routes/modules.js'), 'utf8');
|
|
142
|
+
assert.match(src, /router\.post\('\/modules\/testing', auth\.requireBuilder, auth\.requirePermission\('module\.enable'\)/);
|
|
143
|
+
assert.match(src, /router\.post\('\/modules\/:key\/general-only', auth\.requireBuilder, auth\.requirePermission\('module\.enable'\)/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('POST /modules/testing: switches on and off, and says what it means', async () => {
|
|
147
|
+
rows.clear();
|
|
148
|
+
const { handle } = handlerFor(buildModulesRouter(), 'post', '/modules/testing');
|
|
149
|
+
const on = await invoke(handle, { body: { tester: true } });
|
|
150
|
+
assert.equal(on.body.tester, true);
|
|
151
|
+
assert.match(on.body.note, /new module versions first/);
|
|
152
|
+
const off = await invoke(handle, { body: { tester: false } });
|
|
153
|
+
assert.equal(off.body.tester, false);
|
|
154
|
+
assert.match(off.body.note, /back on general versions from its next update/);
|
|
155
|
+
const bad = await invoke(handle, { body: { tester: 'on' } });
|
|
156
|
+
assert.equal(bad.statusCode, 400, 'an affirmative act is a boolean, not a string that looks like one');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('POST /modules/:key/general-only: only a module this project has', async () => {
|
|
160
|
+
rows.clear();
|
|
161
|
+
const { handle } = handlerFor(buildModulesRouter(), 'post', '/modules/:key/general-only');
|
|
162
|
+
const known = listModules()[0].key;
|
|
163
|
+
const ok = await invoke(handle, { params: { key: known }, body: { general_only: true } });
|
|
164
|
+
assert.equal(ok.statusCode, 200);
|
|
165
|
+
assert.equal(ok.body.general_only_this, true);
|
|
166
|
+
assert.deepEqual(ok.body.general_only, [known]);
|
|
167
|
+
const missing = await invoke(handle, { params: { key: 'no-such-module-here' }, body: { general_only: true } });
|
|
168
|
+
assert.equal(missing.statusCode, 404);
|
|
169
|
+
const badKey = await invoke(handle, { params: { key: 'Not_A_Key' }, body: { general_only: true } });
|
|
170
|
+
assert.equal(badKey.statusCode, 400);
|
|
171
|
+
});
|