@bongos/core 1.19.325 → 1.19.327

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.
@@ -0,0 +1,263 @@
1
+ // tests/help_requests.mjs — "I am stuck": a help request names who it needs
2
+ // (task 1003171 / C01, goal 1000075, criterion collab-ask-for-help).
3
+ //
4
+ // The premise the whole goal rests on: the person you need is often NOT another
5
+ // one of you. So a request addresses a CRAFT or one specific person — and the
6
+ // interesting cases are the refusals, because a request addressed to everyone is
7
+ // a broadcast and one addressed to nobody cannot be routed at all.
8
+ import { strict as assert } from 'node:assert';
9
+ import { test } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const help = require(path.join(ROOT, 'modules', 'lifecycle', 'help-requests.js'));
18
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
19
+ const reasons = (input) => help.validateHelpRequest(input).errors.map((e) => e.reason);
20
+
21
+ // ---- the addressee rules --------------------------------------------------
22
+ test('a craft request is valid', () => {
23
+ assert.deepEqual(reasons({ needs_craft: 'artist', what_is_stuck: 'is this readable?' }), []);
24
+ });
25
+
26
+ test('a person request is valid', () => {
27
+ assert.deepEqual(reasons({ needs_builder_id: 42, what_is_stuck: 'stuck on the migration' }), []);
28
+ });
29
+
30
+ test('addressed to NOBODY is refused', () => {
31
+ // Unroutable: there is no one to notify.
32
+ assert.ok(reasons({ what_is_stuck: 'help' }).includes('no_addressee'));
33
+ });
34
+
35
+ test('addressed to BOTH is refused, not resolved', () => {
36
+ // Picking one would send the request somewhere the asker did not choose.
37
+ assert.ok(reasons({ needs_craft: 'artist', needs_builder_id: 7, what_is_stuck: 'help' })
38
+ .includes('two_addressees'));
39
+ });
40
+
41
+ test('an unknown craft is refused', () => {
42
+ assert.ok(reasons({ needs_craft: 'wizard', what_is_stuck: 'help' }).includes('unknown_craft'));
43
+ });
44
+
45
+ test('the crafts are ADR 0174\'s three', () => {
46
+ assert.deepEqual(help.helpCrafts().sort(), ['artist', 'builder', 'ideator']);
47
+ });
48
+
49
+ test('THE VOCABULARY SEAM: builder maps to the discipline spelled engineer', () => {
50
+ // builders.preferred_disciplines spells this craft 'engineer'. If C02 re-derives
51
+ // the mapping instead of consuming disciplineForCraft, the two spellings drift
52
+ // and a builder silently matches nothing.
53
+ assert.equal(help.disciplineForCraft('builder'), 'engineer');
54
+ assert.equal(help.disciplineForCraft('artist'), 'artist');
55
+ assert.equal(help.disciplineForCraft('ideator'), 'ideator');
56
+ assert.equal(help.disciplineForCraft('wizard'), null);
57
+ });
58
+
59
+ // ---- what is stuck --------------------------------------------------------
60
+ test('what_is_stuck is required and must carry content', () => {
61
+ for (const v of [undefined, '', ' ', '\n\t ']) {
62
+ assert.ok(reasons({ needs_craft: 'artist', what_is_stuck: v }).includes('required'),
63
+ `${JSON.stringify(v)} is not a description of being stuck`);
64
+ }
65
+ });
66
+
67
+ test('an over-long description is refused rather than truncated', () => {
68
+ const long = 'x'.repeat(help.MAX_STUCK + 1);
69
+ assert.ok(reasons({ needs_craft: 'artist', what_is_stuck: long }).includes('too_long'));
70
+ });
71
+
72
+ // ---- context --------------------------------------------------------------
73
+ test('a request may name a task OR a goal, never both', () => {
74
+ assert.deepEqual(reasons({ needs_craft: 'artist', what_is_stuck: 'x', task_id: 1 }), []);
75
+ assert.deepEqual(reasons({ needs_craft: 'artist', what_is_stuck: 'x', goal_id: 1 }), []);
76
+ assert.ok(reasons({ needs_craft: 'artist', what_is_stuck: 'x', task_id: 1, goal_id: 2 })
77
+ .includes('two_contexts'), 'two contexts means two answers to "what is this about"');
78
+ });
79
+
80
+ test('context is optional', () => {
81
+ assert.deepEqual(reasons({ needs_craft: 'ideator', what_is_stuck: 'a shape question' }), []);
82
+ });
83
+
84
+ // ---- the schema keeps the same promises -----------------------------------
85
+ test('the CHECK constraints mirror the validator, so neither is the only guard', () => {
86
+ // The validator exists so a refusal names the field; the constraints are the
87
+ // authority. Both must exist or one of the two promises is unkept.
88
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_004_help_requests.sql');
89
+ assert.match(sql, /needs_craft IN \('artist', 'builder', 'ideator'\)/);
90
+ assert.match(sql, /\(needs_craft IS NOT NULL\) <> \(needs_builder_id IS NOT NULL\)/, 'exactly-one-addressee');
91
+ assert.match(sql, /NOT \(task_id IS NOT NULL AND goal_id IS NOT NULL\)/, 'at most one context');
92
+ assert.match(sql, /length\(btrim\(what_is_stuck\)\) > 0/, 'non-empty description');
93
+ });
94
+
95
+ test('the table is module-owned and lifecycle-namespaced', () => {
96
+ // A NEW table the module owns, unlike goals/tasks which are core tables it only
97
+ // queries — so fitness check 11 permits it (ADR 0083 §Decision #5).
98
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_004_help_requests.sql');
99
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS lifecycle_help_requests/);
100
+ assert.ok(!/ALTER TABLE (?!lifecycle_)/.test(sql), 'it alters no core table');
101
+ });
102
+
103
+ test('sub-speciality is deliberately NOT modelled yet', () => {
104
+ // The criterion says "and later on sub-speciality". Modelling it now would
105
+ // invent a taxonomy before anyone has asked a real question through this.
106
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_004_help_requests.sql');
107
+ // Assert on the COLUMN LIST, not the file: the first version of this matched
108
+ // "speciality" anywhere outside a `--` comment and flagged the migration's own
109
+ // COMMENT ON TABLE, which says it is deliberately not modelled. Same
110
+ // over-broad-source-assertion reflex as learning 1000196.
111
+ const body = sql.slice(sql.indexOf('CREATE TABLE'), sql.indexOf(');'));
112
+ const columns = body.split('\n')
113
+ .map((l) => l.replace(/--.*$/, '').trim())
114
+ .filter((l) => l && !/^(CONSTRAINT|CHECK|\))/.test(l));
115
+ assert.ok(!columns.some((l) => /special/i.test(l)), `no speciality column: ${columns.filter((l) => /special/i.test(l))}`);
116
+ });
117
+
118
+ // ---- the read that makes a request answerable -----------------------------
119
+ test('the addressed read excludes your own requests', () => {
120
+ // Answering your own request is not help.
121
+ const src = read('modules', 'lifecycle', 'help-requests.js');
122
+ const fn = src.slice(src.indexOf('async function listOpenForBuilder'));
123
+ assert.match(fn.slice(0, 700), /requested_by <> \$1/);
124
+ assert.match(fn.slice(0, 700), /status = 'open'/, 'and settled requests are history');
125
+ });
126
+
127
+ test('only the ASKER can settle their own request', () => {
128
+ const src = read('modules', 'lifecycle', 'help-requests.js');
129
+ const fn = src.slice(src.indexOf('async function setHelpRequestStatus'));
130
+ assert.match(fn.slice(0, 600), /requested_by = \$2/, 'ownership is in the WHERE, not in rank');
131
+ assert.match(fn.slice(0, 600), /status = 'open'/, 'and a settled request cannot be re-settled');
132
+ });
133
+
134
+ // ---- it stands alone ------------------------------------------------------
135
+ test('C01 depends on nothing social — it must stay independently shippable', () => {
136
+ const src = read('modules', 'lifecycle', 'help-requests.js');
137
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
138
+ for (const forbidden of ['closeness', 'mingle', 'agora', 'Agora']) {
139
+ assert.ok(!new RegExp(`require\\([^)]*${forbidden}`, 'i').test(src + routes),
140
+ `must not depend on ${forbidden}`);
141
+ }
142
+ });
143
+
144
+ test('the route is registered in the module manifest', () => {
145
+ // A route file nothing mounts is a file, not a surface.
146
+ const manifest = JSON.parse(read('modules', 'lifecycle', 'module.json'));
147
+ assert.ok(manifest.contributes.routes.includes('help-requests'));
148
+ });
149
+
150
+ // ---- the four grader findings from the first pass -------------------------
151
+ const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
152
+
153
+ test('THE BLOCKER: a request REACHES people, it is not merely stored', () => {
154
+ // The criterion is "...and it reaches the right craft". Storing is not reaching.
155
+ // The reach lands in the needs roster the hall card and the CLI nudge already
156
+ // render, rather than in a second notification system invented beside it.
157
+ const n = needs.helpRequestsNeed({ openHelpRequests: 2 });
158
+ assert.ok(n, 'a pending request produces a need');
159
+ assert.equal(n.state, 'action_needed', 'a person is waiting on a human answer');
160
+ assert.match(n.message, /2 people/);
161
+ assert.ok(n.action && n.action.href, 'and it says where to go');
162
+ });
163
+
164
+ test('no requests produces NO need, not a cheerful empty state', () => {
165
+ // An empty state is not an input the system needs from you.
166
+ for (const ctx of [{ openHelpRequests: 0 }, {}, { openHelpRequests: undefined }]) {
167
+ assert.equal(needs.helpRequestsNeed(ctx), null);
168
+ }
169
+ });
170
+
171
+ test('the need is registered, so every renderer picks it up', () => {
172
+ const roster = needs.computeNeeds({ openHelpRequests: 1, ownKeySet: true, rank: 'metic' });
173
+ assert.ok(roster.items.some((i) => i.id === 'help_requests'), 'it is in the roster');
174
+ assert.ok(roster.action_needed_count >= 1);
175
+ });
176
+
177
+ test('THE PRIVACY BUG: craft comes from the caller, never from the query', () => {
178
+ // The first version took ?craft= and validated it only against the enum, so any
179
+ // builder could read every request addressed to a craft they do not belong to
180
+ // by trying each value. The param may now only NARROW what the caller has.
181
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
182
+ assert.match(routes, /craftsForBuilder\(req\.builder\.id\)/, "the caller's own crafts are read");
183
+ const forMe = routes.slice(routes.indexOf("'GET /help-requests/for-me'"));
184
+ const body = forMe.slice(0, forMe.indexOf('errorCode'));
185
+ assert.match(body, /mine\.filter\(/, 'the query param only intersects');
186
+ assert.ok(!/listOpenForBuilder\(\{[^}]*craft: (want|req\.query)/.test(body),
187
+ 'the raw query value never reaches the read');
188
+ });
189
+
190
+ test('a builder with no declared craft sees only requests addressed BY NAME', () => {
191
+ // Fail-closed: belonging to a craft is a claim about yourself the system must
192
+ // not assume on your behalf.
193
+ const src = read('modules', 'lifecycle', 'help-requests.js');
194
+ const fn = src.slice(src.indexOf('async function craftsForBuilder'));
195
+ assert.match(fn.slice(0, 500), /preferred_disciplines/);
196
+ assert.match(fn.slice(0, 500), /filter\(Boolean\)/, 'unmapped disciplines drop out');
197
+ });
198
+
199
+ test('the settle refusal no longer lets you enumerate ids', () => {
200
+ // The comment claimed one refusal for three cases; the code looked the row up
201
+ // and said which. Comment and code now agree.
202
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
203
+ const patch = routes.slice(routes.indexOf("'PATCH /help-requests/:id'"));
204
+ assert.ok(!/getHelpRequest/.test(patch), 'no existence lookup in the refusal path');
205
+ const reasons = patch.match(/reason: '[^']+'/g) || [];
206
+ assert.equal(reasons.length, 1, `exactly one refusal reason, got ${reasons}`);
207
+ });
208
+
209
+ test('both reads are capped', () => {
210
+ assert.equal(help.capRows(5), 5);
211
+ assert.equal(help.capRows(999999), help.MAX_ROWS, 'an absurd limit is capped');
212
+ for (const bad of [0, -1, null, 'x', 1.5]) {
213
+ assert.equal(help.capRows(bad), help.MAX_ROWS, `${bad} falls back to the cap`);
214
+ }
215
+ });
216
+
217
+ test('core reaches the count through the PORT, never by importing lifecycle', () => {
218
+ const me = read('src', 'bongos', 'routes', 'me.js');
219
+ assert.match(me, /openHelpRequestsFor/);
220
+ assert.ok(!/require\([^)]*modules\/lifecycle/.test(me), 'no module import in the kernel (ADR 0093)');
221
+ // The fail-open lives in the fan-out's IIFE now, not a standalone try/catch —
222
+ // the property is what matters, so assert the property.
223
+ const fan = me.slice(me.indexOf('] = await Promise.all(['));
224
+ const iife = fan.slice(fan.indexOf('openHelpRequestsFor') - 400, fan.indexOf('openHelpRequestsFor') + 300);
225
+ assert.match(iife, /catch \(_\) \{ return 0; \}/, 'fail-open: /me must not break on a collab read');
226
+ });
227
+
228
+ // ---- the second-pass grader findings --------------------------------------
229
+ test('the /me read is IN the fan-out, not a serialized await before it', () => {
230
+ // /me is the session-open hot path; every other per-builder read is batched
231
+ // into one Promise.all to keep latency at one round-trip depth. The first
232
+ // version added ~2 serialized round trips in front of it (grader).
233
+ const me = read('src', 'bongos', 'routes', 'me.js');
234
+ const fanStart = me.indexOf('] = await Promise.all([');
235
+ const fanEnd = me.indexOf('\n ]);', fanStart);
236
+ assert.ok(fanStart > 0 && fanEnd > fanStart);
237
+ const fan = me.slice(fanStart, fanEnd);
238
+ assert.match(fan, /openHelpRequestsFor/, 'the read is inside the fan-out');
239
+ assert.match(me.slice(0, fanStart).slice(-2000), /helpRequestCount,/, 'and destructured from it');
240
+ assert.ok(!/helpRequestCount = await /.test(me), 'no standalone await remains');
241
+ });
242
+
243
+ test('the count is ONE query, and its craft map is generated from the JS map', () => {
244
+ // Two sequential queries on a hot path is a cost every builder pays on every
245
+ // open. The SQL CASE is generated from DISCIPLINE_TO_CRAFT so the two spellings
246
+ // of 'engineer' cannot drift apart.
247
+ const sql = help.craftMapSql('d');
248
+ assert.match(sql, /WHEN 'engineer' THEN 'builder'/);
249
+ for (const craft of help.helpCrafts()) {
250
+ const disc = help.disciplineForCraft(craft);
251
+ assert.ok(sql.includes(`WHEN '${disc}' THEN '${craft}'`), `${disc}->${craft} is in the SQL map`);
252
+ }
253
+ const src = read('modules', 'lifecycle', 'help-requests.js');
254
+ const fn = src.slice(src.indexOf('async function countOpenForBuilder'));
255
+ const body = fn.slice(0, fn.indexOf('\n}'));
256
+ assert.equal((body.match(/activePool\.query/g) || []).length, 1, 'exactly one round trip');
257
+ });
258
+
259
+ test('a help request addressed to a non-existent builder is a named 400', () => {
260
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
261
+ assert.match(routes, /builderExists/);
262
+ assert.match(routes, /no_such_builder/, 'the refusal names the field, not an FK 500');
263
+ });