@bongos/core 1.19.273 → 1.19.275

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,571 @@
1
+ // tests/idea_routing.mjs
2
+ //
3
+ // The CAPTURE-TIME ROUTING CORE — task 1003065 (BV1.R01, goal 1000071 "Idea
4
+ // routing — work that has a home lands in it"). Spec:
5
+ // docs/specs/bongos-v1-idea-routing.md.
6
+ //
7
+ // TWO THINGS UNDER TEST, and the split is the design:
8
+ // 1. modules/ideas/routing.js — the landing matrix. PURE, so every cell is
9
+ // pinned here directly, with no Postgres and no fixture.
10
+ // 2. modules/ideas/inbox.js captureIdea — the ONE TRANSACTION a routed capture
11
+ // runs. Driven through the #538 `deps.pool` seam (NODE_ENV==='test' only)
12
+ // with a SQL-aware fake client, the tests/goal_tier.mjs posture: we assert
13
+ // the STATEMENT SEQUENCE and the bound params, because the sequence IS the
14
+ // contract — an idea row, a task, and a lineage row that commit together or
15
+ // not at all.
16
+ //
17
+ // WHY THE ROLLBACK CASES ARE THE HEART OF THIS FILE. This goal exists because
18
+ // Discord #bugs creates tasks with no idea row and no lineage, so the ideator
19
+ // credit lane (goal 1000067) has nothing to pay against. A routed capture that
20
+ // committed a task and lost its lineage row would manufacture that exact bug on
21
+ // the path whose whole promise is that attribution survives (criterion C3). So
22
+ // "a failure after the task insert leaves NOTHING behind" is not an edge case
23
+ // here — it is the property.
24
+ //
25
+ // Run: node tests/idea_routing.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
30
+
31
+ process.env.NODE_ENV = 'test';
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const routing = require('../modules/ideas/routing.js');
35
+ const { decideLanding, routedPriority, DEFAULT_ROUTED_PRIORITY } = routing;
36
+
37
+ // The lifecycle port is how the ideas module reaches createTask (never an
38
+ // import — the one-way module rule, ADR 0083). Register a stub provider, the
39
+ // production wiring, exactly as tests/onboarding_route_signals.mjs does.
40
+ const seams = require('../src/module-seams.js');
41
+ seams._reset();
42
+ let createTaskCalls = [];
43
+ let createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
44
+ seams.registerProvider('lifecycle', {
45
+ createTask: async (args, deps) => {
46
+ createTaskCalls.push({ args, deps });
47
+ return createTaskImpl(args, deps);
48
+ },
49
+ });
50
+
51
+ const inbox = require('../modules/ideas/inbox.js');
52
+ const { captureIdea } = inbox;
53
+
54
+ const { test, summary } = makeRunner();
55
+
56
+ const norm = (sql) => String(sql).replace(/\s+/g, ' ').trim();
57
+ const find = (queries, re) => queries.find((q) => re.test(norm(q.sql)));
58
+ const seq = (queries) => queries.map((q) => norm(q.sql));
59
+
60
+ // A routable goal: open, on a version that is still building.
61
+ const LIVE_GOAL = { id: 7, status: 'open', version_id: 'BONGOS-V1', version_status: 'building' };
62
+ const IDEA_ROW = {
63
+ id: 555, title: 'the thing is broken', body_md: 'steps', kind: 'bug',
64
+ suggested_version: null, suggested_priority: null, captured_at: 'T0', captured_by: 42,
65
+ status: 'promoted', promoted_to_task_id: null, reviewed_at: 'T0',
66
+ merged_into_idea_id: null, triage_note: null, suggested_goal_id: 7,
67
+ };
68
+
69
+ // A fake pool whose `connect()` and `query()` share ONE call log, so a test can
70
+ // assert the whole transaction in order. Mirrors tests/goal_tier.mjs's fakePool.
71
+ function fakePool(handler) {
72
+ const c = makeSqlAwareClient(handler);
73
+ return {
74
+ query: (sql, params) => c._client.query(sql, params),
75
+ connect: c.connect,
76
+ queries: c._client.queries,
77
+ };
78
+ }
79
+
80
+ // The happy-path handler: a live goal, an idea row, and empty results elsewhere.
81
+ function routableHandler(overrides = {}) {
82
+ return (s) => {
83
+ if (/FROM goals g JOIN versions v/.test(s)) {
84
+ return { rows: overrides.goalRows ?? [LIVE_GOAL] };
85
+ }
86
+ if (/INSERT INTO idea_inbox/.test(s)) return { rows: [{ ...IDEA_ROW, ...(overrides.idea || {}) }] };
87
+ return null;
88
+ };
89
+ }
90
+
91
+ function reset() {
92
+ createTaskCalls = [];
93
+ createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
94
+ }
95
+
96
+ // =========================================================================
97
+ // 1. The landing matrix — pure, every cell
98
+ // =========================================================================
99
+
100
+ await test('matrix: NO GOAL means the inbox, and that is the default path', () => {
101
+ for (const goalId of [null, undefined, '']) {
102
+ const d = decideLanding({ goalId, kind: 'bug', filerRank: 'archon' });
103
+ assert.equal(d.route, false, `goalId=${JSON.stringify(goalId)} must not route`);
104
+ assert.equal(d.reason, 'no_goal');
105
+ }
106
+ // A bug filed by an Archon is the MOST routable thing there is — proving it
107
+ // still stays put without a goal is what pins "routing is strictly additive".
108
+ });
109
+
110
+ await test('matrix: a bug from a trusted filer HARD-LANDS at ready (criterion C1)', () => {
111
+ for (const rank of ['metic', 'archon', 'ARCHON', 'Metic']) {
112
+ const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
113
+ assert.deepEqual(d, { route: true, status: 'ready', reason: 'bug_trusted_filer' }, `rank ${rank}`);
114
+ }
115
+ });
116
+
117
+ await test('matrix: a bug from a sub-Metic filer soft-lands, awaiting a nod', () => {
118
+ for (const rank of ['xenos', 'thetes', 'XENOS']) {
119
+ const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
120
+ assert.deepEqual(d, { route: true, status: 'backlog', reason: 'bug_awaiting_nod' }, `rank ${rank}`);
121
+ }
122
+ });
123
+
124
+ await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-lands', () => {
125
+ // The property that matters: no rank input can reach 'ready' except a real
126
+ // trusted tier. A landing decision that misfires puts unvetted work in the
127
+ // claimable queue, so every uncertainty resolves downward.
128
+ for (const rank of [null, undefined, '', 'archon-ish', 'admin', 'root', 0, {}, []]) {
129
+ const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
130
+ assert.equal(d.status, 'backlog', `rank ${JSON.stringify(rank)} must not reach ready`);
131
+ assert.equal(d.reason, 'bug_awaiting_nod');
132
+ }
133
+ });
134
+
135
+ await test('matrix: every non-bug kind soft-lands, whoever files it (interview Q1)', () => {
136
+ // "Bugs are urgent-true; the rest gets a nod." cleanup/refactor/infra were the
137
+ // open question the owner settled — they soft-land like features, not like bugs.
138
+ for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'learning-capture',
139
+ 'blocker-resolution', 'unclassified', 'spike']) {
140
+ const d = decideLanding({ goalId: 7, kind, filerRank: 'archon' });
141
+ assert.deepEqual(d, { route: true, status: 'backlog', reason: 'soft_landing' }, `kind ${kind}`);
142
+ }
143
+ });
144
+
145
+ await test('matrix: a Full Idea never auto-routes — it is the Board Room\'s subject', () => {
146
+ for (const grade of ['full', 'FULL', 'Full']) {
147
+ const d = decideLanding({ goalId: 7, kind: 'feature', grade, filerRank: 'archon' });
148
+ assert.deepEqual(d, { route: false, reason: 'full_idea_never_routes' }, `grade ${grade}`);
149
+ }
150
+ // ...while quick/spark/absent route exactly as their kind says.
151
+ for (const grade of ['quick', 'spark', null, undefined]) {
152
+ assert.equal(decideLanding({ goalId: 7, kind: 'feature', grade }).route, true, `grade ${grade}`);
153
+ }
154
+ });
155
+
156
+ await test('matrix: judgment kinds never auto-route', () => {
157
+ for (const kind of ['criterion-proposal', 'decision']) {
158
+ assert.deepEqual(
159
+ decideLanding({ goalId: 7, kind, filerRank: 'archon' }),
160
+ { route: false, reason: 'judgment_kind' },
161
+ `kind ${kind}`
162
+ );
163
+ }
164
+ });
165
+
166
+ await test('matrix: decideLanding never throws, on any input', () => {
167
+ for (const args of [undefined, {}, null && {}, { goalId: 7 }, { goalId: 7, kind: null }]) {
168
+ assert.doesNotThrow(() => decideLanding(args));
169
+ }
170
+ assert.equal(decideLanding().route, false, 'no args ⇒ no route');
171
+ });
172
+
173
+ await test('routedPriority: the filer\'s suggestion when sane, else the middle of the band', () => {
174
+ for (const p of [1, 2, 3, 4, 5]) assert.equal(routedPriority(p), p);
175
+ for (const p of [0, 6, -1, 99, null, undefined, 'high', 2.5, NaN]) {
176
+ assert.equal(routedPriority(p), DEFAULT_ROUTED_PRIORITY, `${JSON.stringify(p)} ⇒ default`);
177
+ }
178
+ assert.equal(DEFAULT_ROUTED_PRIORITY, 3);
179
+ });
180
+
181
+ // =========================================================================
182
+ // 2. captureIdea — the NON-routing path is byte-for-byte today's behaviour
183
+ // =========================================================================
184
+
185
+ await test('capture with no goal: ONE insert, status open, no transaction at all', async () => {
186
+ reset();
187
+ const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
188
+ ? { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null, reviewed_at: null }] }
189
+ : null));
190
+ const idea = await captureIdea({ title: 'a thought', capturedBy: 42 }, { pool });
191
+ const sqls = seq(pool.queries);
192
+ assert.equal(sqls.filter((q) => /^BEGIN$/.test(q)).length, 0, 'no transaction is opened');
193
+ assert.equal(sqls.length, 1, 'exactly one statement');
194
+ assert.match(sqls[0], /INSERT INTO idea_inbox/);
195
+ assert.match(sqls[0], /'open'/, "status is 'open'");
196
+ assert.equal(createTaskCalls.length, 0, 'no task is created');
197
+ assert.deepEqual(idea.routing, { routed: false, reason: 'no_goal' });
198
+ });
199
+
200
+ await test('a named goal the matrix DECLINES is kept as the core_222 hint, not dropped', async () => {
201
+ reset();
202
+ const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
203
+ ? { rows: [{ ...IDEA_ROW, kind: 'decision', status: 'open' }] } : null));
204
+ const idea = await captureIdea(
205
+ { title: 'which cache?', kind: 'decision', goalId: 7, capturedBy: 42, filerRank: 'archon' },
206
+ { pool }
207
+ );
208
+ const ins = find(pool.queries, /INSERT INTO idea_inbox/);
209
+ assert.match(norm(ins.sql), /suggested_goal_id/, 'the hint column is written');
210
+ assert.equal(ins.params[6], 7, 'the declined goal is carried forward as the hint');
211
+ assert.equal(createTaskCalls.length, 0, 'a judgment kind mints no task');
212
+ assert.deepEqual(idea.routing, { routed: false, reason: 'judgment_kind' },
213
+ 'and the reason says WHY it declined, so a filer is not left guessing');
214
+ });
215
+
216
+ await test('the pre-routing validations still fire before any write', async () => {
217
+ reset();
218
+ const pool = fakePool(() => null);
219
+ await assert.rejects(() => captureIdea({ title: '', goalId: 7 }, { pool }),
220
+ (e) => e.code === 'TITLE_REQUIRED');
221
+ await assert.rejects(() => captureIdea({ title: 'x', kind: 'nonsense', goalId: 7 }, { pool }),
222
+ (e) => e.code === 'BAD_KIND');
223
+ await assert.rejects(() => captureIdea({ title: 'x', suggestedPriority: 9, goalId: 7 }, { pool }),
224
+ (e) => e.code === 'BAD_PRIORITY');
225
+ assert.equal(pool.queries.length, 0, 'a bad payload touches the database zero times');
226
+ });
227
+
228
+ // =========================================================================
229
+ // 3. captureIdea — the ROUTED path: one transaction, in order
230
+ // =========================================================================
231
+
232
+ await test('a routed bug: idea + task + lineage in ONE transaction, in FK order', async () => {
233
+ reset();
234
+ createTaskImpl = async () => ({ id: 9001, status: 'ready', goal_id: 7, version_id: 'BONGOS-V1' });
235
+ const pool = fakePool(routableHandler());
236
+ const out = await captureIdea(
237
+ { title: 'the thing is broken', bodyMd: 'steps', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
238
+ { pool }
239
+ );
240
+ const sqls = seq(pool.queries);
241
+ assert.equal(sqls[0], 'BEGIN', 'opens a transaction first');
242
+ assert.equal(sqls[sqls.length - 1], 'COMMIT', 'and commits last');
243
+
244
+ // The order is forced by the foreign keys, not by taste: lineage references
245
+ // BOTH rows, so idea → task → lineage is the only legal sequence.
246
+ const iGoal = sqls.findIndex((q) => /FROM goals g JOIN versions v/.test(q));
247
+ const iIdea = sqls.findIndex((q) => /INSERT INTO idea_inbox/.test(q));
248
+ const iLin = sqls.findIndex((q) => /INSERT INTO ideas_task_lineage/.test(q));
249
+ const iUpd = sqls.findIndex((q) => /UPDATE idea_inbox SET promoted_to_task_id/.test(q));
250
+ assert.ok(iGoal > 0 && iGoal < iIdea, 'the goal is validated BEFORE anything is written');
251
+ assert.ok(iIdea < iLin, 'the idea row exists before the lineage row references it');
252
+ assert.ok(iLin < iUpd, 'and promoted_to_task_id is closed out last');
253
+
254
+ // The idea is 'promoted' from birth — routing's claim is that it never needed
255
+ // a triage pass, and reviewed_at is stamped so the triage walk skips it.
256
+ const ins = find(pool.queries, /INSERT INTO idea_inbox/);
257
+ assert.match(norm(ins.sql), /'promoted'/, "status is 'promoted', not 'open'");
258
+ assert.match(norm(ins.sql), /reviewed_at/, 'reviewed_at is stamped');
259
+
260
+ // origin='route' — the third provenance ideas_003 widened the CHECK for.
261
+ const lin = find(pool.queries, /INSERT INTO ideas_task_lineage/);
262
+ assert.match(norm(lin.sql), /'route'/, "origin is 'route', not 'promote'");
263
+ assert.deepEqual(lin.params, [9001, 555], 'lineage binds (task_id, idea_id)');
264
+
265
+ assert.equal(out.routing.routed, true);
266
+ assert.equal(out.routing.task_id, 9001);
267
+ assert.equal(out.promoted_to_task_id, 9001, 'the returned row already reflects the link');
268
+ assert.equal(out.routed_task.id, 9001, 'and the task itself comes back');
269
+ });
270
+
271
+ await test('the task INSERT is ENLISTED in the transaction — the whole point', async () => {
272
+ reset();
273
+ const pool = fakePool(routableHandler());
274
+ await captureIdea(
275
+ { title: 'broken', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'metic' },
276
+ { pool }
277
+ );
278
+ assert.equal(createTaskCalls.length, 1);
279
+ const { args, deps } = createTaskCalls[0];
280
+ assert.ok(deps && deps.client, 'createTask is handed the tx client, not left on the pool');
281
+ assert.equal(typeof deps.client.query, 'function');
282
+ // If this ever regresses to a pool write, the task commits independently of
283
+ // its lineage row and we have rebuilt the Discord #bugs bug.
284
+ });
285
+
286
+ await test('the routed task carries the goal, the matrix status, and honest provenance', async () => {
287
+ reset();
288
+ const pool = fakePool(routableHandler());
289
+ await captureIdea(
290
+ { title: 'broken', bodyMd: 'the body', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
291
+ { pool }
292
+ );
293
+ const { args } = createTaskCalls[0];
294
+ assert.equal(args.goalId, 7, 'in the goal it was filed against');
295
+ assert.equal(args.versionId, 'BONGOS-V1', "on the GOAL'S version, never a caller-supplied one");
296
+ assert.equal(args.status, 'ready', 'at the matrix status');
297
+ assert.equal(args.kind, 'bug');
298
+ assert.equal(args.title, 'broken');
299
+ assert.equal(args.description, 'the body');
300
+ assert.equal(args.source, 'idea-route');
301
+ assert.equal(args.sourceRef, 'idea-555', 'keyed to the idea, so one idea mints one task');
302
+ // Deliberately absent: an explicit rank floor and a reward. createTask derives
303
+ // the ADR 0084/0085 floor itself and auto-assigns the capped reward; a filer
304
+ // must not be able to hand either one in.
305
+ assert.equal(args.requiresRank, undefined, 'no filer-supplied rank floor');
306
+ assert.equal(args.creditsReward, undefined, 'no filer-supplied reward');
307
+ });
308
+
309
+ await test('priority: the filer\'s suggestion rides through, else the band middle', async () => {
310
+ reset();
311
+ let pool = fakePool(routableHandler({ idea: { suggested_priority: 1 } }));
312
+ await captureIdea({ title: 'x', kind: 'bug', goalId: 7, suggestedPriority: 1, filerRank: 'archon' }, { pool });
313
+ assert.equal(createTaskCalls[0].args.priority, 1);
314
+
315
+ reset();
316
+ pool = fakePool(routableHandler());
317
+ await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
318
+ assert.equal(createTaskCalls[0].args.priority, DEFAULT_ROUTED_PRIORITY);
319
+ });
320
+
321
+ // =========================================================================
322
+ // 4. The refusals — a named goal that is not a LIVE HOME
323
+ // =========================================================================
324
+
325
+ await test('a goal that does not exist: 404-shaped refusal, nothing written', async () => {
326
+ reset();
327
+ const pool = fakePool(routableHandler({ goalRows: [] }));
328
+ await assert.rejects(
329
+ () => captureIdea({ title: 'x', kind: 'bug', goalId: 404, filerRank: 'archon' }, { pool }),
330
+ (e) => e.code === 'GOAL_NOT_FOUND' && e.goal_id === 404
331
+ );
332
+ assert.equal(find(pool.queries, /INSERT INTO idea_inbox/), undefined, 'no idea row');
333
+ assert.equal(createTaskCalls.length, 0, 'no task');
334
+ assert.ok(find(pool.queries, /^ROLLBACK$/), 'and the transaction is rolled back');
335
+ });
336
+
337
+ await test('an achieved or archived goal is not a home', async () => {
338
+ for (const status of ['achieved', 'archived']) {
339
+ reset();
340
+ const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, status }] }));
341
+ await assert.rejects(
342
+ () => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
343
+ (e) => e.code === 'GOAL_NOT_OPEN' && e.status === status,
344
+ `goal status ${status}`
345
+ );
346
+ assert.equal(createTaskCalls.length, 0);
347
+ assert.ok(find(pool.queries, /^ROLLBACK$/));
348
+ }
349
+ });
350
+
351
+ await test('THE 1000771 FAILURE: a non-building version can never receive routed work', async () => {
352
+ // The bug this whole goal was filed over — Discord #bugs hardcoded a SHIPPED
353
+ // version, so every reported bug landed in a shipped version's catch-all where
354
+ // nobody would look again. A route refuses rather than filing into a tomb.
355
+ for (const version_status of ['shipped', 'planning', 'frozen']) {
356
+ reset();
357
+ const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, version_status }] }));
358
+ await assert.rejects(
359
+ () => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
360
+ (e) => e.code === 'VERSION_NOT_BUILDING' && e.version_status === version_status,
361
+ `version status ${version_status}`
362
+ );
363
+ assert.equal(createTaskCalls.length, 0, `no task on a ${version_status} version`);
364
+ }
365
+ });
366
+
367
+ // =========================================================================
368
+ // 5. Atomicity — the property, not an edge case
369
+ // =========================================================================
370
+
371
+ await test('a task-create failure leaves NO orphan idea row (rollback)', async () => {
372
+ reset();
373
+ createTaskImpl = async () => { throw new Error('tasks table is on fire'); };
374
+ const pool = fakePool(routableHandler());
375
+ await assert.rejects(
376
+ () => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
377
+ /on fire/
378
+ );
379
+ assert.ok(find(pool.queries, /INSERT INTO idea_inbox/), 'the insert was attempted...');
380
+ assert.ok(find(pool.queries, /^ROLLBACK$/), '...and then rolled back');
381
+ assert.equal(find(pool.queries, /^COMMIT$/), undefined, 'never committed');
382
+ });
383
+
384
+ await test('a LINEAGE failure rolls the task back too — attribution or nothing (C3)', async () => {
385
+ reset();
386
+ const pool = fakePool((s) => {
387
+ if (/INSERT INTO ideas_task_lineage/.test(s)) throw new Error('lineage check violated');
388
+ return routableHandler()(s);
389
+ });
390
+ await assert.rejects(
391
+ () => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
392
+ /lineage/
393
+ );
394
+ assert.ok(find(pool.queries, /^ROLLBACK$/));
395
+ assert.equal(find(pool.queries, /^COMMIT$/), undefined,
396
+ 'a task with no lineage row IS the bug this goal abolishes — it must not commit');
397
+ });
398
+
399
+ await test('the goal row is LOCKED, so it cannot be archived mid-route', async () => {
400
+ reset();
401
+ const pool = fakePool(routableHandler());
402
+ await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
403
+ const sel = find(pool.queries, /FROM goals g JOIN versions v/);
404
+ assert.match(norm(sel.sql), /FOR SHARE OF g/, 'the goal row is held for the transaction');
405
+ assert.match(norm(sel.sql), /v\.status AS version_status/,
406
+ 'and the version status is read in the SAME statement, not a second round trip');
407
+ });
408
+
409
+ await test('no lifecycle port ⇒ a loud refusal, never a silent inbox row', async () => {
410
+ reset();
411
+ const saved = seams.resolveOptional('lifecycle');
412
+ seams._reset();
413
+ try {
414
+ const pool = fakePool(routableHandler());
415
+ await assert.rejects(
416
+ () => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
417
+ (e) => e.code === 'LIFECYCLE_UNAVAILABLE'
418
+ );
419
+ assert.ok(find(pool.queries, /^ROLLBACK$/));
420
+ // The alternative — degrading to an inbox row — would tell the filer their
421
+ // bug became a task when it did not. Refusing is the honest failure.
422
+ } finally {
423
+ seams._reset();
424
+ seams.registerProvider('lifecycle', saved);
425
+ }
426
+ });
427
+
428
+ // =========================================================================
429
+ // 6. THE ROUTE — POST /inbox, through the real handler
430
+ //
431
+ // The layer test above proves the transaction. This proves the CONTRACT a client
432
+ // sees: where the filer's rank comes from, what the refusals look like, and that
433
+ // omitting goal_id leaves the endpoint exactly as it was. Driven the
434
+ // tests/idea_full_capture.mjs way — patch the doorway pool, pull the real
435
+ // handler off the mounted router, stub req/res — so the same validator and the
436
+ // same error map a client hits are the ones under test.
437
+ // =========================================================================
438
+
439
+ const apiDoorway = require('../src/module-api.js');
440
+
441
+ let routeGoalRows = [LIVE_GOAL];
442
+ apiDoorway.pool.query = async (sql) => {
443
+ if (/SELECT COUNT|count\(/i.test(String(sql))) return { rows: [{ n: '3', count: '3' }] };
444
+ if (/INSERT INTO idea_inbox/i.test(String(sql))) {
445
+ return { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null }] };
446
+ }
447
+ return { rows: [] };
448
+ };
449
+ apiDoorway.pool.connect = async () => makeSqlAwareClient((s) => {
450
+ if (/FROM goals g JOIN versions v/.test(s)) return { rows: routeGoalRows };
451
+ if (/INSERT INTO idea_inbox/.test(s)) return { rows: [IDEA_ROW] };
452
+ return null;
453
+ }).connect();
454
+
455
+ const postInbox = (() => {
456
+ const router = require('../modules/ideas/routes/inbox.js')({});
457
+ const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
458
+ const handlers = layer.route.stack.map((h) => h.handle);
459
+ return handlers[handlers.length - 1]; // past requireBuilder
460
+ })();
461
+
462
+ // The res double the ADR 0116 envelope needs: res.fail is what every refusal in
463
+ // this module calls, so a test that only implements .json would see a crash
464
+ // instead of the 4xx a client gets.
465
+ function call(body, builder = { id: '42', rank: 'archon' }) {
466
+ return new Promise((resolve) => {
467
+ const out = { status: 200, body: null };
468
+ const res = {
469
+ status(c) { out.status = c; return this; },
470
+ json(b) { out.body = b; resolve(out); return this; },
471
+ fail(code, statusOrOpts, details) {
472
+ const st = typeof statusOrOpts === 'number' ? statusOrOpts : (statusOrOpts && statusOrOpts.status) || 400;
473
+ out.status = st;
474
+ out.body = { error: { code, details: details || null } };
475
+ resolve(out);
476
+ return this;
477
+ },
478
+ };
479
+ Promise.resolve(postInbox({ body, builder, query: {} }, res)).catch((e) => {
480
+ out.status = 500; out.body = { threw: String(e && e.message) }; resolve(out);
481
+ });
482
+ });
483
+ }
484
+
485
+ await test('route: POST /inbox with a goal_id returns the TASK it became', async () => {
486
+ reset();
487
+ routeGoalRows = [LIVE_GOAL];
488
+ createTaskImpl = async () => ({ id: 9001, status: 'ready', goal_id: 7, version_id: 'BONGOS-V1' });
489
+ const r = await call({ title: 'the thing is broken', kind: 'bug', goal_id: 7 });
490
+ assert.equal(r.status, 201, JSON.stringify(r.body));
491
+ assert.equal(r.body.routed_task.id, 9001, 'the task is at the TOP level, not buried in the idea');
492
+ assert.equal(r.body.idea.routing.routed, true);
493
+ assert.equal(r.body.idea.routing.reason, 'bug_trusted_filer');
494
+ assert.equal(r.body.idea.routed_task, undefined, 'and is not duplicated inside the idea row');
495
+ });
496
+
497
+ await test('route: THE FILER\'S RANK COMES FROM AUTH, never from the body', async () => {
498
+ // The property that decides whether filing can outrun rank (criterion C4),
499
+ // asserted from both sides.
500
+ //
501
+ // (a) A forged rank in the body is refused outright — the BV1.R109c
502
+ // unknown-field validator answers before the handler runs. A stronger
503
+ // answer than ignoring it, and it means the matrix never sees the value.
504
+ reset();
505
+ routeGoalRows = [LIVE_GOAL];
506
+ const forged = await call({ title: 'broken', kind: 'bug', goal_id: 7, rank: 'archon' }, { id: '99', rank: 'xenos' });
507
+ assert.equal(forged.status, 400, JSON.stringify(forged.body));
508
+ assert.equal(forged.body.error.code, 'validation_failed');
509
+ assert.deepEqual(forged.body.error.details, [{ field: 'rank', reason: 'unknown_field' }]);
510
+ assert.equal(createTaskCalls.length, 0, 'and nothing was created');
511
+
512
+ // (b) With an IDENTICAL legal body, the landing differs purely by the
513
+ // authenticated builder's rank — which is the positive proof that
514
+ // req.builder.rank is what reaches the matrix.
515
+ const body = { title: 'broken', kind: 'bug', goal_id: 7 };
516
+ reset();
517
+ createTaskImpl = async (a) => ({ id: 1, status: a.status, goal_id: 7 });
518
+ const asArchon = await call({ ...body }, { id: '1', rank: 'archon' });
519
+ assert.equal(asArchon.status, 201, JSON.stringify(asArchon.body));
520
+ assert.equal(asArchon.body.idea.routing.status, 'ready');
521
+
522
+ reset();
523
+ createTaskImpl = async (a) => ({ id: 2, status: a.status, goal_id: 7 });
524
+ const asXenos = await call({ ...body }, { id: '2', rank: 'xenos' });
525
+ assert.equal(asXenos.status, 201, JSON.stringify(asXenos.body));
526
+ assert.equal(asXenos.body.idea.routing.status, 'backlog',
527
+ 'same request, lower rank ⇒ soft landing: filing cannot outrun rank');
528
+ });
529
+
530
+ await test('route: a sub-Metic filer\'s bug lands at backlog, awaiting a nod', async () => {
531
+ reset();
532
+ routeGoalRows = [LIVE_GOAL];
533
+ createTaskImpl = async (a) => ({ id: 9002, status: a.status, goal_id: 7 });
534
+ const r = await call({ title: 'broken', kind: 'bug', goal_id: 7 }, { id: '99', rank: 'thetes' });
535
+ assert.equal(r.status, 201, JSON.stringify(r.body));
536
+ assert.equal(r.body.idea.routing.status, 'backlog');
537
+ assert.equal(r.body.idea.routing.reason, 'bug_awaiting_nod');
538
+ });
539
+
540
+ await test('route: the refusals map to 404 / 409, with the offending id echoed', async () => {
541
+ reset();
542
+ routeGoalRows = [];
543
+ let r = await call({ title: 'x', kind: 'bug', goal_id: 4040 });
544
+ assert.equal(r.status, 404, JSON.stringify(r.body));
545
+ assert.equal(r.body.error.code, 'GOAL_NOT_FOUND');
546
+
547
+ reset();
548
+ routeGoalRows = [{ ...LIVE_GOAL, status: 'archived' }];
549
+ r = await call({ title: 'x', kind: 'bug', goal_id: 7 });
550
+ assert.equal(r.status, 409, JSON.stringify(r.body));
551
+ assert.equal(r.body.error.code, 'GOAL_NOT_OPEN');
552
+
553
+ reset();
554
+ routeGoalRows = [{ ...LIVE_GOAL, version_status: 'shipped' }];
555
+ r = await call({ title: 'x', kind: 'bug', goal_id: 7 });
556
+ assert.equal(r.status, 409, JSON.stringify(r.body));
557
+ assert.equal(r.body.error.code, 'VERSION_NOT_BUILDING');
558
+ });
559
+
560
+ await test('route: NO goal_id is still the plain old capture — 201, no task', async () => {
561
+ reset();
562
+ const r = await call({ title: 'just a thought' });
563
+ assert.equal(r.status, 201, JSON.stringify(r.body));
564
+ assert.equal(r.body.routed_task, undefined, 'nothing routed');
565
+ assert.equal(createTaskCalls.length, 0, 'no task created');
566
+ assert.equal(r.body.idea.routing.reason, 'no_goal');
567
+ assert.ok('open_count' in r.body, 'and the pre-routing response shape is intact');
568
+ assert.ok('kind_auto' in r.body);
569
+ });
570
+
571
+ summary('idea_routing');