@bongos/core 1.20.69 → 1.20.71
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 +48 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +50 -3
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module-assess-score.js +111 -0
- package/scripts/gds/module-assess-security.js +1 -0
- package/scripts/gds/module-assess-tests.js +1 -0
- package/scripts/gds/module-assess-version.js +82 -0
- package/scripts/gds/module.js +85 -7
- package/scripts/gds/upgrade.js +1 -1
- package/src/bongos/module-store.js +25 -1
- package/src/bongos/routes/modules.js +33 -1
- package/src/module-api.js +1 -1
- package/tests/module_assess_score.mjs +276 -0
- package/tests/module_cli.mjs +7 -7
- package/tests/module_upgrade_updates.mjs +256 -0
package/tests/module_cli.mjs
CHANGED
|
@@ -156,14 +156,14 @@ test('gatherUpgrade: a satisfied enabled module passes; the same module disabled
|
|
|
156
156
|
loader._reset();
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
test('cmdUpgrade: exit 0 with no modules, exit 1 when an enabled module is incompatible', () => {
|
|
159
|
+
test('cmdUpgrade: exit 0 with no modules, exit 1 when an enabled module is incompatible', async () => {
|
|
160
160
|
const lines = [];
|
|
161
161
|
const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
|
|
162
162
|
|
|
163
|
-
const empty = mod.cmdUpgrade([], { ...sink, gather: () => ({ rows: [], errors: [], core: '1.0.0' }) });
|
|
163
|
+
const empty = await mod.cmdUpgrade([], { checkUpdates: async () => ({ checked: true, latest: {} }), ...sink, gather: () => ({ rows: [], errors: [], core: '1.0.0' }) });
|
|
164
164
|
assert.equal(empty, 0);
|
|
165
165
|
|
|
166
|
-
const bad = mod.cmdUpgrade([], {
|
|
166
|
+
const bad = await mod.cmdUpgrade([], { checkUpdates: async () => ({ checked: true, latest: {} }),
|
|
167
167
|
...sink,
|
|
168
168
|
gather: () => ({ core: '1.0.0', errors: [], rows: [{ key: 'future', version: '1.0.0', coreVersion: '^2.0.0', enabled: true, satisfied: false }] }),
|
|
169
169
|
});
|
|
@@ -474,13 +474,13 @@ test('gatherUpgrade: carries each module\'s maintenance posture onto its row', (
|
|
|
474
474
|
loader._reset();
|
|
475
475
|
});
|
|
476
476
|
|
|
477
|
-
test('cmdUpgrade: a DISABLED incompatible module is reported with its owner, not silently dropped', () => {
|
|
477
|
+
test('cmdUpgrade: a DISABLED incompatible module is reported with its owner, not silently dropped', async () => {
|
|
478
478
|
// The catalog-rot case: an accepted module ships default:false (ADR 0107 §6), so
|
|
479
479
|
// every rotted catalog entry is disabled by construction. Exit stays 0 — it breaks
|
|
480
480
|
// nothing at boot — but it must be visible and attributed.
|
|
481
481
|
const lines = [];
|
|
482
482
|
const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
|
|
483
|
-
const code = mod.cmdUpgrade([], {
|
|
483
|
+
const code = await mod.cmdUpgrade([], { checkUpdates: async () => ({ checked: true, latest: {} }),
|
|
484
484
|
...sink,
|
|
485
485
|
gather: () => ({
|
|
486
486
|
core: '2.0.0',
|
|
@@ -498,10 +498,10 @@ test('cmdUpgrade: a DISABLED incompatible module is reported with its owner, not
|
|
|
498
498
|
assert.match(out, /maintainer Ada <ada@example\.com>/, 'the owner is named so someone can be told');
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
-
test('cmdUpgrade: reports an expired deprecation and modules with no declared upkeep', () => {
|
|
501
|
+
test('cmdUpgrade: reports an expired deprecation and modules with no declared upkeep', async () => {
|
|
502
502
|
const lines = [];
|
|
503
503
|
const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
|
|
504
|
-
const code = mod.cmdUpgrade([], {
|
|
504
|
+
const code = await mod.cmdUpgrade([], { checkUpdates: async () => ({ checked: true, latest: {} }),
|
|
505
505
|
...sink,
|
|
506
506
|
gather: () => ({
|
|
507
507
|
core: '1.13.0',
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// tests/module_upgrade_updates.mjs — `bongos upgrade` reports available module
|
|
2
|
+
// updates beside coreVersion drift (task 1003787, ADR 0338 D1, criterion
|
|
3
|
+
// wa5-module-economy). Two halves, no port and no real DB:
|
|
4
|
+
// - the CLI: cmdUpgrade against injected rows and a fake store, so one command
|
|
5
|
+
// answers "what is stale here" — and the exit code is still decided by the
|
|
6
|
+
// compatibility check alone (an incompatible ENABLED module blocks, a disabled
|
|
7
|
+
// one never does, an update never does);
|
|
8
|
+
// - the server: the real GET /store/modules/latest handler off the real router,
|
|
9
|
+
// with pool.js replaced in the require cache (the module_store_howto.mjs harness).
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/module_upgrade_updates.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// ---- the fake registry the route reads ----
|
|
20
|
+
const state = { modules: {}, versions: [] };
|
|
21
|
+
const pool = {
|
|
22
|
+
async query(sql, params = []) {
|
|
23
|
+
if (/FROM store_module_versions v JOIN store_modules m/.test(sql)) {
|
|
24
|
+
return {
|
|
25
|
+
rows: state.versions
|
|
26
|
+
.filter((v) => params[0].includes(v.module_key) && state.modules[v.module_key])
|
|
27
|
+
.map((v) => ({ ...v, status: state.modules[v.module_key].status })),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { rows: [] };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
34
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
35
|
+
|
|
36
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
37
|
+
const mod = require('../scripts/gds/module.js');
|
|
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
|
+
fail(code, opts) { this.statusCode = opts.status; this.body = { error: code, ...opts }; return this; },
|
|
53
|
+
};
|
|
54
|
+
let nextErr = null;
|
|
55
|
+
await handle(req, res, (err) => { nextErr = err; });
|
|
56
|
+
if (nextErr) throw nextErr;
|
|
57
|
+
return res;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const row = (key, version, extra = {}) => ({ key, version, coreVersion: '^1.0.0', enabled: true, satisfied: true, maintenance: { status: 'maintained' }, maintainer: 'Ada', ...extra });
|
|
61
|
+
const sink = () => { const out = []; const err = []; return { out, err, log: (s) => out.push(String(s)), errlog: (s) => err.push(String(s)) }; };
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// the decision (pure)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
test('evaluateUpdates: only a NEWER store version is an update; equal, older and unknown are not', () => {
|
|
68
|
+
const rows = [row('weather', '1.0.0'), row('clock', '2.0.0'), row('dice', '1.5.0'), row('built-here', '0.1.0')];
|
|
69
|
+
const latest = {
|
|
70
|
+
weather: { version: '1.2.0', core_version: '^1.0.0', status: 'listed' },
|
|
71
|
+
clock: { version: '2.0.0', core_version: '^1.0.0', status: 'listed' },
|
|
72
|
+
dice: { version: '1.4.0', core_version: '^1.0.0', status: 'listed' },
|
|
73
|
+
};
|
|
74
|
+
const { updates, delisted } = mod.evaluateUpdates(rows, latest, '1.19.0');
|
|
75
|
+
assert.deepEqual(updates.map((u) => [u.key, u.installed, u.latest, u.fits]), [['weather', '1.0.0', '1.2.0', true]]);
|
|
76
|
+
assert.deepEqual(delisted, []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('evaluateUpdates: semver order, not text — 1.10.0 is newer than 1.9.0', () => {
|
|
80
|
+
const { updates } = mod.evaluateUpdates([row('weather', '1.9.0')], { weather: { version: '1.10.0', core_version: '^1.0.0', status: 'listed' } }, '1.19.0');
|
|
81
|
+
assert.equal(updates.length, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('evaluateUpdates: an update that needs a newer core is reported as waiting, not as runnable', () => {
|
|
85
|
+
const { updates } = mod.evaluateUpdates([row('weather', '1.0.0')], { weather: { version: '2.0.0', core_version: '^2.0.0', status: 'listed' } }, '1.19.0');
|
|
86
|
+
assert.equal(updates.length, 1);
|
|
87
|
+
assert.equal(updates[0].fits, false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('evaluateUpdates: a delisted module is reported apart, never offered as an update', () => {
|
|
91
|
+
const { updates, delisted } = mod.evaluateUpdates([row('weather', '1.0.0', { enabled: false })], { weather: { version: '1.2.0', core_version: '^1.0.0', status: 'delisted' } }, '1.19.0');
|
|
92
|
+
assert.deepEqual(updates, []);
|
|
93
|
+
assert.deepEqual(delisted.map((d) => d.key), ['weather']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('evaluateUpdates: a row with no exact version has nothing to compare against', () => {
|
|
97
|
+
const { updates } = mod.evaluateUpdates([row('weather', null)], { weather: { version: '1.2.0', core_version: '^1.0.0', status: 'listed' } }, '1.19.0');
|
|
98
|
+
assert.deepEqual(updates, []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// gatherUpdates (the store read)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
test('gatherUpdates: one read for every versioned module, keys in the query string', async () => {
|
|
106
|
+
const calls = [];
|
|
107
|
+
const request = async (method, urlPath) => { calls.push([method, urlPath]); return { ok: true, status: 200, data: { latest: { weather: { version: '1.2.0' } } } }; };
|
|
108
|
+
const got = await mod.gatherUpdates([row('weather', '1.0.0'), row('clock', '2.0.0'), row('loose', null)], { request });
|
|
109
|
+
assert.deepEqual(calls, [['GET', '/store/modules/latest?keys=weather,clock']]);
|
|
110
|
+
assert.equal(got.checked, true);
|
|
111
|
+
assert.equal(got.latest.weather.version, '1.2.0');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('gatherUpdates: it never acquires — no POST, so asking takes no entitlement', async () => {
|
|
115
|
+
const methods = [];
|
|
116
|
+
await mod.gatherUpdates([row('weather', '1.0.0')], { request: async (m) => { methods.push(m); return { ok: true, status: 200, data: { latest: {} } }; } });
|
|
117
|
+
assert.deepEqual(methods, ['GET']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('gatherUpdates: an unreachable store or a refusal is "not checked", never a throw', async () => {
|
|
121
|
+
const down = await mod.gatherUpdates([row('weather', '1.0.0')], { request: async () => { throw new Error('ECONNREFUSED'); } });
|
|
122
|
+
assert.equal(down.checked, false);
|
|
123
|
+
assert.match(down.reason, /could not be reached/);
|
|
124
|
+
const refused = await mod.gatherUpdates([row('weather', '1.0.0')], { request: async () => ({ ok: false, status: 401, data: { error: { message: 'session expired' } } }) });
|
|
125
|
+
assert.equal(refused.checked, false);
|
|
126
|
+
assert.match(refused.reason, /session expired/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('gatherUpdates: nothing versioned means nothing to ask — no store call at all', async () => {
|
|
130
|
+
const got = await mod.gatherUpdates([row('loose', null)], { request: async () => { throw new Error('must not be called'); } });
|
|
131
|
+
assert.deepEqual(got, { checked: true, latest: {} });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// cmdUpgrade — one report, the old exit-code asymmetry intact
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const storeSays = (latest) => async () => ({ checked: true, latest });
|
|
139
|
+
|
|
140
|
+
test('cmdUpgrade: lists an available update beside the compatibility rows, and still exits 0', async () => {
|
|
141
|
+
const s = sink();
|
|
142
|
+
const code = await mod.cmdUpgrade([], {
|
|
143
|
+
...s,
|
|
144
|
+
gather: () => ({ core: '1.19.0', errors: [], rows: [row('weather', '1.0.0')] }),
|
|
145
|
+
checkUpdates: storeSays({ weather: { version: '1.2.0', core_version: '^1.0.0', status: 'listed' } }),
|
|
146
|
+
});
|
|
147
|
+
assert.equal(code, 0, 'an update is news, never a refusal');
|
|
148
|
+
const out = s.out.join('\n');
|
|
149
|
+
assert.match(out, /✓ weather/, 'the compatibility row is still there');
|
|
150
|
+
assert.match(out, /Module updates \(from the store\)/);
|
|
151
|
+
assert.match(out, /weather\s+1\.0\.0 → 1\.2\.0\s+— run `bongos module update weather`/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('cmdUpgrade: an incompatible ENABLED module still refuses, with the updates reported too', async () => {
|
|
155
|
+
const s = sink();
|
|
156
|
+
const code = await mod.cmdUpgrade([], {
|
|
157
|
+
...s,
|
|
158
|
+
gather: () => ({ core: '2.0.0', errors: [], rows: [row('weather', '1.0.0', { coreVersion: '^1.0.0', satisfied: false })] }),
|
|
159
|
+
checkUpdates: storeSays({ weather: { version: '2.0.0', core_version: '^2.0.0', status: 'listed' } }),
|
|
160
|
+
});
|
|
161
|
+
assert.equal(code, 1, 'the block is unchanged');
|
|
162
|
+
assert.match(s.err.join('\n'), /REFUSING: 1 enabled module/);
|
|
163
|
+
assert.match(s.out.join('\n'), /weather\s+1\.0\.0 → 2\.0\.0\s+— run `bongos module update weather`/, 'and the way out is named: the newer version fits this core');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('cmdUpgrade: an incompatible DISABLED module never blocks, update or not', async () => {
|
|
167
|
+
const s = sink();
|
|
168
|
+
const code = await mod.cmdUpgrade([], {
|
|
169
|
+
...s,
|
|
170
|
+
gather: () => ({ core: '2.0.0', errors: [], rows: [row('weather', '1.0.0', { enabled: false, satisfied: false })] }),
|
|
171
|
+
checkUpdates: storeSays({ weather: { version: '1.5.0', core_version: '^1.0.0', status: 'listed' } }),
|
|
172
|
+
});
|
|
173
|
+
assert.equal(code, 0);
|
|
174
|
+
assert.match(s.out.join('\n'), /needs core \^1\.0\.0; this core is 2\.0\.0, so it waits until the core moves/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('cmdUpgrade: nothing newer says so in one line', async () => {
|
|
178
|
+
const s = sink();
|
|
179
|
+
await mod.cmdUpgrade([], { ...s, gather: () => ({ core: '1.19.0', errors: [], rows: [row('weather', '1.2.0')] }), checkUpdates: storeSays({ weather: { version: '1.2.0', core_version: '^1.0.0', status: 'listed' } }) });
|
|
180
|
+
assert.match(s.out.join('\n'), /every installed store module is on its newest version/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('cmdUpgrade: a delisted installed module is flagged', async () => {
|
|
184
|
+
const s = sink();
|
|
185
|
+
await mod.cmdUpgrade([], { ...s, gather: () => ({ core: '1.19.0', errors: [], rows: [row('weather', '1.2.0')] }), checkUpdates: storeSays({ weather: { version: '1.2.0', core_version: '^1.0.0', status: 'delisted' } }) });
|
|
186
|
+
assert.match(s.err.join('\n'), /weather.*delisted in the store: it keeps working, but no further versions will come/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('cmdUpgrade: an unreachable store costs the update list, never the pre-check or its exit code', async () => {
|
|
190
|
+
const s = sink();
|
|
191
|
+
const code = await mod.cmdUpgrade([], { ...s, gather: () => ({ core: '1.19.0', errors: [], rows: [row('weather', '1.0.0')] }), checkUpdates: async () => ({ checked: false, reason: 'no Bongos session — run `bongos setup` first' }) });
|
|
192
|
+
assert.equal(code, 0);
|
|
193
|
+
const out = s.out.join('\n');
|
|
194
|
+
assert.match(out, /not checked — no Bongos session/);
|
|
195
|
+
assert.match(out, /OK — every enabled module satisfies core 1\.19\.0/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('cmdUpgrade --no-store: the store is not asked at all', async () => {
|
|
199
|
+
const s = sink();
|
|
200
|
+
const code = await mod.cmdUpgrade(['--no-store'], { ...s, gather: () => ({ core: '1.19.0', errors: [], rows: [row('weather', '1.0.0')] }), checkUpdates: async () => { throw new Error('must not be called'); } });
|
|
201
|
+
assert.equal(code, 0);
|
|
202
|
+
assert.doesNotMatch(s.out.join('\n'), /Module updates/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('gatherUpgrade rows carry the installed version, so the store can be compared against', () => {
|
|
206
|
+
const data = mod.gatherUpgrade({ env: {}, neutral: {}, instance: {} });
|
|
207
|
+
const versioned = data.rows.filter((r) => r.version);
|
|
208
|
+
assert.ok(versioned.length > 0, 'the core modules declare a version');
|
|
209
|
+
for (const r of versioned) assert.equal(typeof r.version, 'string');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// GET /store/modules/latest — the server half
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const router = buildModulesRouter();
|
|
217
|
+
const latestRoute = handlerFor(router, 'get', '/store/modules/latest');
|
|
218
|
+
const ask = (keys) => invoke(latestRoute.handle, { query: keys == null ? {} : { keys }, builder: { id: 7 } });
|
|
219
|
+
|
|
220
|
+
state.modules = {
|
|
221
|
+
weather: { module_key: 'weather', status: 'listed' },
|
|
222
|
+
gone: { module_key: 'gone', status: 'delisted' },
|
|
223
|
+
};
|
|
224
|
+
state.versions = [
|
|
225
|
+
{ module_key: 'weather', version: '1.9.0', core_version: '^1.0.0' },
|
|
226
|
+
{ module_key: 'weather', version: '1.10.0', core_version: '^1.2.0' },
|
|
227
|
+
{ module_key: 'weather', version: '1.2.0', core_version: '^1.0.0' },
|
|
228
|
+
{ module_key: 'gone', version: '0.3.0', core_version: '^1.0.0' },
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
test('the route is open to any signed-in builder: requireBuilder and no permission gate', () => {
|
|
232
|
+
assert.deepEqual(latestRoute.names.slice(0, 1), ['requireBuilder']);
|
|
233
|
+
assert.equal(latestRoute.names.length, 2, 'builder gate, then the handler');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('the route answers the newest version per key, in semver order', async () => {
|
|
237
|
+
const r = await ask('weather');
|
|
238
|
+
assert.equal(r.statusCode, 200, JSON.stringify(r.body));
|
|
239
|
+
assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed' });
|
|
240
|
+
assert.equal(r.headers['Cache-Control'], 'no-store');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('the route still answers a delisted module, with its status; an unknown key is absent', async () => {
|
|
244
|
+
const r = await ask('weather,gone,nope');
|
|
245
|
+
assert.equal(r.body.latest.gone.status, 'delisted');
|
|
246
|
+
assert.equal('nope' in r.body.latest, false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('the route refuses a missing, malformed or oversized key list', async () => {
|
|
250
|
+
assert.equal((await ask(null)).statusCode, 400);
|
|
251
|
+
assert.equal((await ask('')).statusCode, 400);
|
|
252
|
+
assert.equal((await ask('Not_A_Key')).statusCode, 400);
|
|
253
|
+
assert.equal((await ask('../etc')).statusCode, 400);
|
|
254
|
+
const many = Array.from({ length: 201 }, (_, i) => `m-${i}`).join(',');
|
|
255
|
+
assert.equal((await ask(many)).statusCode, 400);
|
|
256
|
+
});
|