@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.
@@ -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
+ });