@bongos/core 1.19.673 → 1.19.675
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 +35 -25
- 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 +6 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +71 -12
- package/docs/api-reference.md +4 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/discord/discord-channels.js +107 -0
- package/modules/discord/routes/discord.js +26 -2
- package/modules/lifecycle/db-tasks.js +22 -0
- package/modules/lifecycle/goal-edits.js +34 -0
- package/modules/lifecycle/routes/done-when.js +114 -17
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/discord-channels.js +14 -3
- package/src/module-api.js +1 -1
- package/tests/criterion_goal_attach.mjs +347 -0
- package/tests/discord_channels_snapshot.mjs +198 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// tests/criterion_goal_attach.mjs
|
|
2
|
+
//
|
|
3
|
+
// Route-level proof that an EXISTING criterion can join a goal, leave one, and
|
|
4
|
+
// that BOTH ends of the move are walled (task 1003509 — walk finding W5).
|
|
5
|
+
//
|
|
6
|
+
// WHY THE ROUTE AND NOT THE PREDICATE. tests/criterion_authoring_gate.mjs already
|
|
7
|
+
// pins authorizeCriterionCreate as a pure decision. The defect this file exists to
|
|
8
|
+
// catch is one layer up and invisible there: a move is a criterion write on the
|
|
9
|
+
// DESTINATION goal as much as on the source, so gating only the goal it leaves
|
|
10
|
+
// would let a goal's own owner push their criterion into a goal they hold no
|
|
11
|
+
// authority over. That failure passes every pure-gate test — the gate is correct,
|
|
12
|
+
// it is simply not asked twice. This route's own history records the same shape
|
|
13
|
+
// once already ("the comment said it, the code did half of it", task 1003102).
|
|
14
|
+
//
|
|
15
|
+
// So this runs the REAL Express router with the data layer and the auth gate
|
|
16
|
+
// stubbed at the module boundary — the tests/task_visual_route_gate.mjs pattern.
|
|
17
|
+
// No DB, no network, no session.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/criterion_goal_attach.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
const express = require('express');
|
|
29
|
+
const api = require('../src/module-api.js');
|
|
30
|
+
|
|
31
|
+
// The REAL updateCriterionGoal SQL runs here, against a recording pool. That
|
|
32
|
+
// matters: db-tasks.js destructures `pool` off the doorway at REQUIRE time and
|
|
33
|
+
// takes no deps seam (its siblings createCriterion/updateTaskGoal take none
|
|
34
|
+
// either, and growing one just for a test would make this function the odd one
|
|
35
|
+
// out), so the pool has to be replaced BEFORE the first require below. Stubbing
|
|
36
|
+
// db.updateCriterionGoal instead would leave its SQL with no coverage at all —
|
|
37
|
+
// a mutation that made the UPDATE write nothing stayed green until this landed.
|
|
38
|
+
const sqlLog = [];
|
|
39
|
+
Object.defineProperty(api, 'pool', {
|
|
40
|
+
configurable: true,
|
|
41
|
+
value: {
|
|
42
|
+
query: async (sql, params) => {
|
|
43
|
+
sqlLog.push({ sql, params });
|
|
44
|
+
if (/UPDATE done_when_criteria/.test(sql)) {
|
|
45
|
+
const [goalId, id] = params;
|
|
46
|
+
criterion = { ...criterion, id: Number(id), goal_id: goalId };
|
|
47
|
+
return { rows: [{ ...criterion }] };
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`unexpected SQL reached the pool: ${sql}`);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const db = require('../modules/lifecycle/db.js');
|
|
55
|
+
const doneWhen = require('../modules/lifecycle/done-when.js');
|
|
56
|
+
const goalEdits = require('../modules/lifecycle/goal-edits.js');
|
|
57
|
+
|
|
58
|
+
// ---- the world the route sees ---------------------------------------------
|
|
59
|
+
const V1 = 'INST-V1';
|
|
60
|
+
const V2 = 'INST-V2';
|
|
61
|
+
const ARCHON = { id: 900, rank: 'archon' };
|
|
62
|
+
const METIC = { id: 700, rank: 'metic' };
|
|
63
|
+
|
|
64
|
+
let actor = METIC;
|
|
65
|
+
let criterion = null;
|
|
66
|
+
const goals = new Map();
|
|
67
|
+
let calls = {};
|
|
68
|
+
let completable = new Set();
|
|
69
|
+
|
|
70
|
+
function resetWorld() {
|
|
71
|
+
actor = METIC;
|
|
72
|
+
completable = new Set([3]);
|
|
73
|
+
sqlLog.length = 0;
|
|
74
|
+
calls = { achieve: [], auditMove: [], prose: [] };
|
|
75
|
+
// Derived from the SQL the real updateCriterionGoal actually sent, as
|
|
76
|
+
// [criterionId, goalId] — so "nothing was written" means no UPDATE reached the
|
|
77
|
+
// pool, not merely that a stub went uncalled.
|
|
78
|
+
Object.defineProperty(calls, 'updateGoal', {
|
|
79
|
+
get: () => sqlLog
|
|
80
|
+
.filter((q) => /UPDATE done_when_criteria/.test(q.sql))
|
|
81
|
+
.map((q) => [Number(q.params[1]), q.params[0]]),
|
|
82
|
+
});
|
|
83
|
+
// The shape a wizard-born instance actually starts with: seeded under V1 by
|
|
84
|
+
// provision-repo.js, belonging to no goal because none existed at standup.
|
|
85
|
+
criterion = { id: 55, version_id: V1, criterion_id: 'first-goal', criterion_md: 'ship something', goal_id: null, satisfied: false, sort_order: 10 };
|
|
86
|
+
goals.clear();
|
|
87
|
+
// id version status created_by (owner)
|
|
88
|
+
goals.set(3, { id: 3, version_id: V1, status: 'open', created_by: METIC.id });
|
|
89
|
+
goals.set(4, { id: 4, version_id: V1, status: 'open', created_by: 999 }); // someone else's
|
|
90
|
+
goals.set(9, { id: 9, version_id: V2, status: 'open', created_by: METIC.id }); // another version
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- stubs at the module boundary -----------------------------------------
|
|
94
|
+
api.requireBuilder = (req, _res, next) => { req.builder = actor; next(); };
|
|
95
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
96
|
+
|
|
97
|
+
doneWhen.getCriterion = async (id) => (criterion && criterion.id === Number(id) ? { ...criterion } : null);
|
|
98
|
+
db.getVersion = async (id) => ({ id, status: 'building' });
|
|
99
|
+
db.getGoal = async (id) => (goals.has(Number(id)) ? { ...goals.get(Number(id)) } : null);
|
|
100
|
+
db.getGoalMember = async () => null; // membership is never the path under test; ownership is
|
|
101
|
+
// NOT stubbed — db.updateCriterionGoal is the real one, running its real SQL
|
|
102
|
+
// against the pool above. `calls.updateGoal` is derived from what that SQL sent.
|
|
103
|
+
db.achieveGoalIfComplete = async ({ goalId }) => {
|
|
104
|
+
calls.achieve.push(goalId);
|
|
105
|
+
// Which goals a re-derive would actually complete is per-test — BOTH ends of a
|
|
106
|
+
// move can be completed by it, and which one matters is the point.
|
|
107
|
+
return { achieved: completable.has(Number(goalId)), goal: goals.get(Number(goalId)) ?? null };
|
|
108
|
+
};
|
|
109
|
+
goalEdits.recordCriterionGoalMove = async (move) => { calls.auditMove.push(move); };
|
|
110
|
+
goalEdits.updateCriterionProse = async ({ criterionId, criterionMd }) => {
|
|
111
|
+
calls.prose.push([criterionId, criterionMd]);
|
|
112
|
+
criterion = { ...criterion, criterion_md: criterionMd };
|
|
113
|
+
return { criterion: { ...criterion }, deltas: [{ field: 'criterion_md' }], unchanged: false };
|
|
114
|
+
};
|
|
115
|
+
goalEdits.recordProseEdit = async () => {};
|
|
116
|
+
|
|
117
|
+
const buildRouter = require('../modules/lifecycle/routes/done-when.js');
|
|
118
|
+
|
|
119
|
+
const app = express();
|
|
120
|
+
app.use(express.json());
|
|
121
|
+
app.use((req, res, next) => {
|
|
122
|
+
res.fail = (code, status, extra) => {
|
|
123
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
124
|
+
return res.status(s).json({ error: code, ...(extra && typeof extra === 'object' ? extra : {}) });
|
|
125
|
+
};
|
|
126
|
+
next();
|
|
127
|
+
});
|
|
128
|
+
app.use(buildRouter());
|
|
129
|
+
|
|
130
|
+
const server = http.createServer(app);
|
|
131
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
132
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
133
|
+
|
|
134
|
+
async function patch(body) {
|
|
135
|
+
const res = await fetch(`${base}/done-when/55`, {
|
|
136
|
+
method: 'PATCH',
|
|
137
|
+
headers: { 'content-type': 'application/json' },
|
|
138
|
+
body: JSON.stringify(body),
|
|
139
|
+
});
|
|
140
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let passed = 0, failed = 0;
|
|
144
|
+
async function test(name, fn) {
|
|
145
|
+
resetWorld();
|
|
146
|
+
try { await fn(); console.log(` ok ${name}`); passed++; }
|
|
147
|
+
catch (err) { console.error(` FAIL ${name}\n ${err.message}`); failed++; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
await test('the ungrouped V1 criterion joins a goal — the W5 fix', async () => {
|
|
153
|
+
// The founder of a wizard-born instance IS its Archon, which is what makes the
|
|
154
|
+
// W5 case reachable: a goal-LESS criterion is version-level, and ADR 0154 keeps
|
|
155
|
+
// version-level criteria Archon-only. That predates this task — the same gate
|
|
156
|
+
// already refused a non-Archon PROSE edit on an ungrouped criterion.
|
|
157
|
+
actor = ARCHON;
|
|
158
|
+
const r = await patch({ goal_id: 3 });
|
|
159
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
160
|
+
assert.equal(r.body.criterion.goal_id, 3);
|
|
161
|
+
assert.deepEqual(calls.updateGoal, [[55, 3]]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('BOTH ends are gated: owning the source does not license the destination', async () => {
|
|
165
|
+
// The criterion already sits in goal 3, which METIC owns. Goal 4 is someone
|
|
166
|
+
// else's. Gating only the source would let this through — that is the defect.
|
|
167
|
+
criterion.goal_id = 3;
|
|
168
|
+
const r = await patch({ goal_id: 4 });
|
|
169
|
+
assert.equal(r.status, 403, `expected the destination wall, got ${r.status} ${JSON.stringify(r.body)}`);
|
|
170
|
+
assert.equal(r.body.error, 'goal_not_yours');
|
|
171
|
+
assert.deepEqual(calls.updateGoal, [], 'nothing was written');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('a METIC moves a criterion between two goals they own — no Archon needed', async () => {
|
|
175
|
+
// The other half of the W5 fix: regrouping WITHIN a workspace someone already
|
|
176
|
+
// holds needs no escalation. Goal 7 is a second goal owned by the same Metic.
|
|
177
|
+
goals.set(7, { id: 7, version_id: V1, status: 'open', created_by: METIC.id });
|
|
178
|
+
criterion.goal_id = 3;
|
|
179
|
+
const r = await patch({ goal_id: 7 });
|
|
180
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
181
|
+
assert.equal(r.body.criterion.goal_id, 7);
|
|
182
|
+
assert.deepEqual(calls.auditMove, [{ criterionId: 55, fromGoalId: 3, toGoalId: 7 }]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('a criterion may never cross versions', async () => {
|
|
186
|
+
actor = ARCHON;
|
|
187
|
+
const r = await patch({ goal_id: 9 }); // goal 9 lives on V2, the criterion on V1
|
|
188
|
+
assert.equal(r.status, 400);
|
|
189
|
+
assert.equal(r.body.error, 'bad_goal_id');
|
|
190
|
+
assert.deepEqual(calls.updateGoal, []);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('an absent goal is the same refusal as a foreign-version one', async () => {
|
|
194
|
+
actor = ARCHON;
|
|
195
|
+
const r = await patch({ goal_id: 4242 });
|
|
196
|
+
assert.equal(r.status, 400);
|
|
197
|
+
assert.equal(r.body.error, 'bad_goal_id');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('DETACH is Archon-only — it lands on the gate\'s goal-less branch (ADR 0154)', async () => {
|
|
201
|
+
criterion.goal_id = 3;
|
|
202
|
+
const denied = await patch({ goal_id: null });
|
|
203
|
+
assert.equal(denied.status, 403);
|
|
204
|
+
assert.equal(denied.body.error, 'goal_id_required');
|
|
205
|
+
assert.deepEqual(calls.updateGoal, []);
|
|
206
|
+
|
|
207
|
+
actor = ARCHON;
|
|
208
|
+
const allowed = await patch({ goal_id: null });
|
|
209
|
+
assert.equal(allowed.status, 200, JSON.stringify(allowed.body));
|
|
210
|
+
assert.equal(allowed.body.criterion.goal_id, null);
|
|
211
|
+
assert.deepEqual(calls.updateGoal, [[55, null]]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('a SATISFIED criterion still moves — only its text is frozen', async () => {
|
|
215
|
+
actor = ARCHON;
|
|
216
|
+
criterion.satisfied = true;
|
|
217
|
+
const moved = await patch({ goal_id: 3 });
|
|
218
|
+
assert.equal(moved.status, 200, JSON.stringify(moved.body));
|
|
219
|
+
assert.equal(moved.body.criterion.goal_id, 3);
|
|
220
|
+
|
|
221
|
+
const reworded = await patch({ criterion_md: 'something else' });
|
|
222
|
+
assert.equal(reworded.status, 409);
|
|
223
|
+
assert.equal(reworded.body.error, 'criterion_satisfied');
|
|
224
|
+
assert.deepEqual(calls.prose, [], 'the frozen text was never written');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('adopting a criterion that completes the goal reports the achievement', async () => {
|
|
228
|
+
actor = ARCHON;
|
|
229
|
+
const r = await patch({ goal_id: 3 });
|
|
230
|
+
assert.equal(r.status, 200);
|
|
231
|
+
assert.deepEqual(calls.achieve, [3], 'the destination is re-derived, not assumed');
|
|
232
|
+
assert.equal(r.body.achieved_goals.includes(3), true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('leaving a goal can COMPLETE it — the source is re-derived too', async () => {
|
|
236
|
+
// The half that is easy to miss: pulling an UNSATISFIED criterion out of goal 3
|
|
237
|
+
// can leave everything still in goal 3 satisfied. Nothing else would ever close
|
|
238
|
+
// it — no later ship re-checks that goal, because no task under it changed.
|
|
239
|
+
actor = ARCHON;
|
|
240
|
+
criterion.goal_id = 3;
|
|
241
|
+
completable = new Set([3]); // the goal it LEAVES is the one that completes
|
|
242
|
+
const r = await patch({ goal_id: 4 });
|
|
243
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
244
|
+
assert.deepEqual(calls.achieve, [3, 4], 'both ends re-derived, source first');
|
|
245
|
+
assert.deepEqual(r.body.achieved_goals, [3]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('a DETACH still re-derives the goal it left', async () => {
|
|
249
|
+
actor = ARCHON;
|
|
250
|
+
criterion.goal_id = 3;
|
|
251
|
+
completable = new Set([3]);
|
|
252
|
+
const r = await patch({ goal_id: null });
|
|
253
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
254
|
+
assert.deepEqual(calls.achieve, [3], 'no destination, but the source still counts');
|
|
255
|
+
assert.deepEqual(r.body.achieved_goals, [3]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await test('a move that completes BOTH ends reports both', async () => {
|
|
259
|
+
actor = ARCHON;
|
|
260
|
+
criterion.goal_id = 3;
|
|
261
|
+
completable = new Set([3, 4]);
|
|
262
|
+
const r = await patch({ goal_id: 4 });
|
|
263
|
+
assert.deepEqual(r.body.achieved_goals, [3, 4]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await test('a no-op move writes nothing at all', async () => {
|
|
267
|
+
actor = ARCHON;
|
|
268
|
+
criterion.goal_id = 3;
|
|
269
|
+
const r = await patch({ goal_id: 3 });
|
|
270
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
271
|
+
assert.deepEqual(calls.updateGoal, [], 'no UPDATE reached the pool');
|
|
272
|
+
assert.deepEqual(calls.auditMove, [], 'nothing to audit');
|
|
273
|
+
assert.deepEqual(calls.achieve, [], 'nothing changed, so nothing to re-derive');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('a DETACH from a goal-less criterion is also a no-op', async () => {
|
|
277
|
+
actor = ARCHON;
|
|
278
|
+
const r = await patch({ goal_id: null });
|
|
279
|
+
assert.equal(r.status, 200);
|
|
280
|
+
assert.deepEqual(calls.updateGoal, []);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await test('a DETACH re-derives no DESTINATION — there is none to complete', async () => {
|
|
284
|
+
actor = ARCHON;
|
|
285
|
+
criterion.goal_id = 3;
|
|
286
|
+
completable = new Set(); // nothing completes; only the CALL shape is under test
|
|
287
|
+
const r = await patch({ goal_id: null });
|
|
288
|
+
assert.equal(r.status, 200);
|
|
289
|
+
assert.deepEqual(calls.achieve, [3], 'the source, and only the source');
|
|
290
|
+
assert.equal(r.body.achieved_goals.length, 0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('the move is audited with the goal it LEFT, which the request body cannot show', async () => {
|
|
294
|
+
criterion.goal_id = 3;
|
|
295
|
+
actor = ARCHON;
|
|
296
|
+
await patch({ goal_id: 4 });
|
|
297
|
+
assert.deepEqual(calls.auditMove, [{ criterionId: 55, fromGoalId: 3, toGoalId: 4 }]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await test('prose and goal move in one call: both land, and the response carries both', async () => {
|
|
301
|
+
actor = ARCHON;
|
|
302
|
+
const r = await patch({ criterion_md: 'reworded', goal_id: 3 });
|
|
303
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
304
|
+
assert.deepEqual(calls.prose, [[55, 'reworded']]);
|
|
305
|
+
assert.deepEqual(calls.updateGoal, [[55, 3]]);
|
|
306
|
+
// The goal move happens last, so the returned row must carry BOTH changes —
|
|
307
|
+
// returning the prose result here would report goal_id as it was before.
|
|
308
|
+
assert.equal(r.body.criterion.goal_id, 3);
|
|
309
|
+
assert.equal(r.body.criterion.criterion_md, 'reworded');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await test('an empty patch is refused rather than silently doing nothing', async () => {
|
|
313
|
+
const r = await patch({});
|
|
314
|
+
assert.equal(r.status, 400);
|
|
315
|
+
assert.equal(r.body.error, 'nothing_to_update');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await test('prose-only editing is unchanged (the pre-existing contract)', async () => {
|
|
319
|
+
actor = ARCHON;
|
|
320
|
+
const r = await patch({ criterion_md: 'clearer wording' });
|
|
321
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
322
|
+
assert.deepEqual(calls.prose, [[55, 'clearer wording']]);
|
|
323
|
+
assert.deepEqual(calls.updateGoal, [], 'an absent goal_id never touches the linkage');
|
|
324
|
+
assert.ok(Array.isArray(r.body.deltas) && r.body.deltas.length === 1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await test('the UPDATE sets goal_id from the parameter, keyed by criterion id', async () => {
|
|
328
|
+
// What a recording pool CAN prove is the statement, not its execution — there is
|
|
329
|
+
// no Postgres in this lane. So pin the shape: a SET that writes the bound
|
|
330
|
+
// parameter (not the column back onto itself), keyed by id, RETURNING the row
|
|
331
|
+
// the route hands back. Without this, `SET goal_id = goal_id` is a silent no-op
|
|
332
|
+
// that every behavioural test above still passes, because they read the row this
|
|
333
|
+
// fake returns rather than one a database wrote.
|
|
334
|
+
actor = ARCHON;
|
|
335
|
+
await patch({ goal_id: 3 });
|
|
336
|
+
const update = sqlLog.find((q) => /UPDATE done_when_criteria/.test(q.sql));
|
|
337
|
+
assert.ok(update, 'the move reached the pool at all');
|
|
338
|
+
const sql = update.sql.replace(/\s+/g, ' ').trim();
|
|
339
|
+
assert.match(sql, /SET goal_id = \$1\b/, 'writes the bound parameter, not the column onto itself');
|
|
340
|
+
assert.match(sql, /WHERE id = \$2\b/, 'keyed by the criterion id');
|
|
341
|
+
assert.match(sql, /RETURNING\b[^;]*\bgoal_id\b/, 'returns the moved row, so the response cannot go stale');
|
|
342
|
+
assert.deepEqual(update.params, [3, 55]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await new Promise((r) => server.close(r));
|
|
346
|
+
console.log(`\ncriterion_goal_attach: ${passed} passed, ${failed} failed`);
|
|
347
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// tests/discord_channels_snapshot.mjs
|
|
2
|
+
//
|
|
3
|
+
// Seeding docs/discord/channels.json from the LIVE guild (task 1003510).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. ADR 0037 §1 calls channels.json "the single source of truth"
|
|
6
|
+
// and rests its security argument on it — changing channels means editing a repo
|
|
7
|
+
// file and merging it, and merging is rank-gated, so the layout is Archon-gated
|
|
8
|
+
// by construction. That property only holds for channels the file actually
|
|
9
|
+
// describes. It described one (board-room, task 1003033); the rest of the guild
|
|
10
|
+
// was configured somewhere untracked. Reconstructing them by hand from the ADR's
|
|
11
|
+
// access table was rejected on purpose: the table lists `view` but no names and
|
|
12
|
+
// no topics, so guessing would make the bot EDIT live channels to match an
|
|
13
|
+
// invention. `configFromSnapshot` reads the real values instead.
|
|
14
|
+
//
|
|
15
|
+
// THE PROPERTY THAT MAKES IT TRUSTWORTHY — and the whole point of this file:
|
|
16
|
+
//
|
|
17
|
+
// planChannelReconcile(snapshot, configFromSnapshot(snapshot).config) === no ops
|
|
18
|
+
//
|
|
19
|
+
// Feeding the derived config straight back at the guild it came from must ask for
|
|
20
|
+
// NOTHING. Any field inverted wrongly — a view, a name, a topic, a parent — makes
|
|
21
|
+
// the planner emit an op, so this one assertion tests the whole function instead
|
|
22
|
+
// of restating it field by field. A config that round-trips is one an operator can
|
|
23
|
+
// commit without the next reconcile rewriting the guild.
|
|
24
|
+
//
|
|
25
|
+
// Pure: no Discord, no network, no DB.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/discord_channels_snapshot.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ch = require('../modules/discord/discord-channels.js');
|
|
34
|
+
const { configFromSnapshot, viewFromOverwrites, planChannelReconcile, mergeViewOverwrites, resolveViewRoles } = ch;
|
|
35
|
+
|
|
36
|
+
const EVERYONE = '100';
|
|
37
|
+
const ROLES = { xenos: '201', thetes: '202', metic: '203', archon: '204' };
|
|
38
|
+
const BOT = '999';
|
|
39
|
+
const ctx = { everyoneId: EVERYONE, roleMap: ROLES, botId: BOT };
|
|
40
|
+
|
|
41
|
+
let passed = 0, failed = 0;
|
|
42
|
+
function test(name, fn) {
|
|
43
|
+
try { fn(); console.log(` ok ${name}`); passed++; }
|
|
44
|
+
catch (e) { console.error(` FAIL ${name}\n ${e.message}`); failed++; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build the overwrites a REAL reconciled channel carries for a given view, using
|
|
48
|
+
// the production forward path — so the fixtures are what the bot actually writes,
|
|
49
|
+
// not a hand-rolled guess at it.
|
|
50
|
+
const owFor = (view) => mergeViewOverwrites([], resolveViewRoles(view, ROLES), ctx);
|
|
51
|
+
|
|
52
|
+
// ---- the inverse, on its own ----------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('everyone-visible reads back as "everyone"', () => {
|
|
55
|
+
assert.equal(viewFromOverwrites(owFor('everyone'), ctx), 'everyone');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('each rank round-trips through the overwrites the bot writes', () => {
|
|
59
|
+
for (const rank of ['xenos', 'thetes', 'metic', 'archon']) {
|
|
60
|
+
assert.equal(viewFromOverwrites(owFor(rank), ctx), rank, `view=${rank}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the LOWEST allowed rank wins — view grants that rank and every rank above', () => {
|
|
65
|
+
// thetes' overwrites allow thetes+metic+archon. The answer must be the floor,
|
|
66
|
+
// not whichever happens to be first in the array.
|
|
67
|
+
const ow = owFor('thetes');
|
|
68
|
+
assert.ok(ow.some((o) => o.id === ROLES.archon), 'fixture really does allow archon too');
|
|
69
|
+
assert.equal(viewFromOverwrites(ow, ctx), 'thetes');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no managed overwrite at all reads back as null, never "everyone"', () => {
|
|
73
|
+
// A channel the bot has never touched. Claiming "everyone" here would write a
|
|
74
|
+
// claim the guild never made, and the next reconcile would act on it.
|
|
75
|
+
assert.equal(viewFromOverwrites([], ctx), null);
|
|
76
|
+
assert.equal(viewFromOverwrites([{ id: '777', type: 0, allow: '0', deny: '0' }], ctx), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('@everyone denied with no rank allowed fails safe to archon', () => {
|
|
80
|
+
const ow = [{ id: EVERYONE, type: 0, allow: '0', deny: '1024' }];
|
|
81
|
+
assert.equal(viewFromOverwrites(ow, ctx), 'archon', 'mirrors resolveViewRoles fail-safe');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---- the round trip --------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// A guild shaped like the real one: a public category, a restricted one, emoji in
|
|
87
|
+
// the names, and a channel whose view is TIGHTER than its category's.
|
|
88
|
+
function guild() {
|
|
89
|
+
return {
|
|
90
|
+
categories: [
|
|
91
|
+
{ id: '1', name: 'COMMONS', overwrites: owFor('everyone') },
|
|
92
|
+
{ id: '2', name: 'BUILDERS', overwrites: owFor('xenos') },
|
|
93
|
+
],
|
|
94
|
+
channels: [
|
|
95
|
+
{ id: '10', name: '👋-welcome', type: 0, parentId: '1', topic: 'Start here.', overwrites: owFor('everyone') },
|
|
96
|
+
{ id: '11', name: '📯-ship-news', type: 0, parentId: '1', topic: 'What shipped.', overwrites: owFor('everyone') },
|
|
97
|
+
{ id: '12', name: '🏛️-board-room', type: 0, parentId: '2', topic: 'Ratification votes.', overwrites: owFor('xenos') },
|
|
98
|
+
{ id: '13', name: '🔒-archon-only', type: 0, parentId: '2', topic: 'Archon business.', overwrites: owFor('archon') },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('ROUND TRIP: the derived config asks the planner for nothing', () => {
|
|
104
|
+
const snapshot = guild();
|
|
105
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
106
|
+
assert.deepEqual(warnings, [], 'a fully-reconciled guild produces no warnings');
|
|
107
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
108
|
+
assert.deepEqual(ops, [], `expected zero ops, got: ${JSON.stringify(ops.map((o) => [o.op, o.name]))}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the real names survive, emoji and all — they are what slug matching needs', () => {
|
|
112
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
113
|
+
const names = config.categories.flatMap((c) => c.channels.map((x) => x.name));
|
|
114
|
+
assert.deepEqual(names, ['👋-welcome', '📯-ship-news', '🏛️-board-room', '🔒-archon-only']);
|
|
115
|
+
const keys = config.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
116
|
+
assert.deepEqual(keys, ['welcome', 'ship-news', 'board-room', 'archon-only'], 'key is the slug');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('topics are carried verbatim — the ADR table has none, which is why guessing was refused', () => {
|
|
120
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
121
|
+
const commons = config.categories.find((c) => c.name === 'COMMONS');
|
|
122
|
+
assert.equal(commons.channels.find((c) => c.key === 'ship-news').topic, 'What shipped.');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a channel view EQUAL to its category is omitted; a tighter one is stated', () => {
|
|
126
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
127
|
+
const builders = config.categories.find((c) => c.name === 'BUILDERS');
|
|
128
|
+
assert.equal(builders.view, 'xenos');
|
|
129
|
+
// board-room matches its category → inherits, so no `view` key to drift later.
|
|
130
|
+
assert.equal('view' in builders.channels.find((c) => c.key === 'board-room'), false);
|
|
131
|
+
// archon-only is tighter → must be stated or the config would widen it.
|
|
132
|
+
assert.equal(builders.channels.find((c) => c.key === 'archon-only').view, 'archon');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a tighter channel view is not merely recorded — it survives the round trip', () => {
|
|
136
|
+
// Guard against "stated but wrong": drop the view and the planner must object.
|
|
137
|
+
const snapshot = guild();
|
|
138
|
+
const { config } = configFromSnapshot(snapshot, ctx);
|
|
139
|
+
const builders = config.categories.find((c) => c.name === 'BUILDERS');
|
|
140
|
+
delete builders.channels.find((c) => c.key === 'archon-only').view;
|
|
141
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
142
|
+
assert.ok(ops.length > 0, 'removing the tighter view must make the planner want to widen it');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---- the honest edges ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
test('an uncategorised channel is REPORTED and omitted, never invented into a category', () => {
|
|
148
|
+
const snapshot = guild();
|
|
149
|
+
snapshot.channels.push({ id: '14', name: 'stray', type: 0, parentId: null, topic: '', overwrites: owFor('everyone') });
|
|
150
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
151
|
+
const all = config.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
152
|
+
assert.ok(!all.includes('stray'), 'omitted — channels.json cannot express it');
|
|
153
|
+
assert.ok(warnings.some((w) => /stray/.test(w) && /no category/.test(w)), 'and said so');
|
|
154
|
+
// Omitted means UNTOUCHED, which is the file header's own rule.
|
|
155
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
156
|
+
assert.deepEqual(ops, [], 'an omitted channel still produces no op');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a never-reconciled channel warns instead of silently claiming a view', () => {
|
|
160
|
+
const snapshot = guild();
|
|
161
|
+
snapshot.channels.push({ id: '15', name: 'untouched', type: 0, parentId: '1', topic: '', overwrites: [] });
|
|
162
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
163
|
+
const entry = config.categories.find((c) => c.name === 'COMMONS').channels.find((c) => c.key === 'untouched');
|
|
164
|
+
assert.equal('view' in entry, false, 'inherits the category, as it does in the guild');
|
|
165
|
+
assert.ok(warnings.some((w) => /untouched/.test(w)), 'the operator is told before they apply');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a CATEGORY with no managed overwrite is recorded as everyone, and warns', () => {
|
|
169
|
+
// The category-side twin of the channel null path above. A guild can carry a
|
|
170
|
+
// category the bot has never restricted; channels.json has no way to say
|
|
171
|
+
// "inherits" for a category (it is the top of the tree), so 'everyone' is the
|
|
172
|
+
// only expressible answer — and it is a WIDER claim than the guild made, which
|
|
173
|
+
// is exactly why it has to warn rather than pass silently.
|
|
174
|
+
const snapshot = {
|
|
175
|
+
categories: [{ id: '1', name: 'UNMANAGED', overwrites: [] }],
|
|
176
|
+
channels: [{ id: '10', name: 'chat', type: 0, parentId: '1', topic: '', overwrites: owFor('everyone') }],
|
|
177
|
+
};
|
|
178
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
179
|
+
assert.equal(config.categories[0].view, 'everyone');
|
|
180
|
+
assert.ok(
|
|
181
|
+
warnings.some((w) => /UNMANAGED/.test(w) && /confirm/i.test(w)),
|
|
182
|
+
'the operator is told the category view was assumed, not read'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('the output is the shape channels.json actually declares', () => {
|
|
187
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
188
|
+
assert.deepEqual(Object.keys(config), ['categories']);
|
|
189
|
+
for (const c of config.categories) {
|
|
190
|
+
assert.deepEqual(Object.keys(c).sort(), ['channels', 'name', 'view']);
|
|
191
|
+
for (const x of c.channels) {
|
|
192
|
+
for (const k of Object.keys(x)) assert.ok(['key', 'name', 'topic', 'view'].includes(k), `unexpected key ${k}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log(`\ndiscord_channels_snapshot: ${passed} passed, ${failed} failed`);
|
|
198
|
+
process.exit(failed === 0 ? 0 : 1);
|