@bongos/core 1.19.294 → 1.19.296
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 +37 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +188 -3
- package/docs/api-reference.md +5 -2
- package/docs/file-map.md +1 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/ideas/inbox.js +88 -0
- package/modules/ideas/routes/inbox.js +150 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spark.js +155 -0
- package/src/bongos/route-rank-check.js +9 -0
- package/src/module-api.js +5 -1
- package/tests/idea_spark_mark.mjs +231 -0
- package/tests/idea_spark_queue.mjs +225 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// tests/idea_spark_mark.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002920 (BV1.R144, goal 1000062) — mark a Quick Idea as a Big Idea Spark.
|
|
4
|
+
//
|
|
5
|
+
// The done-when, as assertions: ONLY the original author can mark their idea
|
|
6
|
+
// (the mark is the consent that makes cross-builder development fair, so a
|
|
7
|
+
// non-author is a 403 and writes NOTHING); the mark is REVERSIBLE (DELETE puts
|
|
8
|
+
// the idea back to quick); and a spark is visibly DISTINCT from both a quick and
|
|
9
|
+
// a full idea (the grade comes back on the row, and a Full Idea is refused
|
|
10
|
+
// rather than being flattened into a spark).
|
|
11
|
+
//
|
|
12
|
+
// DB-free the idea_full_capture.mjs way (pool.query patched on the doorway
|
|
13
|
+
// before the modules load), driving the REAL route handlers through a stub
|
|
14
|
+
// req/res — the same author gate, the same error map a client sees.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/idea_spark_mark.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const api = require('../src/module-api.js');
|
|
23
|
+
|
|
24
|
+
// The one idea every test reads, reshaped per-test. `captured_by: '9'` is the
|
|
25
|
+
// stub builder below, so the default row IS author-owned.
|
|
26
|
+
let idea = null;
|
|
27
|
+
let queries = [];
|
|
28
|
+
|
|
29
|
+
api.pool.query = async (sql, params = []) => {
|
|
30
|
+
queries.push({ sql, params });
|
|
31
|
+
if (/INSERT INTO ideas_grades/i.test(sql)) {
|
|
32
|
+
return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
|
|
33
|
+
}
|
|
34
|
+
// Any read of idea_inbox is getIdea — return the row under test (or nothing).
|
|
35
|
+
if (/FROM idea_inbox/i.test(sql)) {
|
|
36
|
+
return { rows: idea ? [idea] : [] };
|
|
37
|
+
}
|
|
38
|
+
return { rows: [] };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
42
|
+
const router = routesFactory({});
|
|
43
|
+
|
|
44
|
+
// Pull the real handlers off the mounted router — the last handler in each
|
|
45
|
+
// stack, i.e. after requireBuilder.
|
|
46
|
+
function handlerFor(pathStr, method) {
|
|
47
|
+
const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
|
|
48
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
|
|
49
|
+
const stack = layer.route.stack.map((s) => s.handle);
|
|
50
|
+
return stack[stack.length - 1];
|
|
51
|
+
}
|
|
52
|
+
const postSpark = handlerFor('/inbox/:id/spark', 'post');
|
|
53
|
+
const deleteSpark = handlerFor('/inbox/:id/spark', 'delete');
|
|
54
|
+
|
|
55
|
+
function call(handler, { builderId = '9' } = {}) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const req = { params: { id: '77' }, body: {}, query: {}, builder: { id: builderId, rank: 'xenos' } };
|
|
58
|
+
const res = {
|
|
59
|
+
statusCode: 200,
|
|
60
|
+
status(c) { this.statusCode = c; return this; },
|
|
61
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
62
|
+
fail(code, status, extra) {
|
|
63
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
64
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
// asyncHandler owns the catch in production; surface a throw as a failure here.
|
|
68
|
+
Promise.resolve(handler(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const gradeWrites = () => queries.filter((q) => /INSERT INTO ideas_grades/i.test(q.sql));
|
|
73
|
+
|
|
74
|
+
let passed = 0;
|
|
75
|
+
let failed = 0;
|
|
76
|
+
async function test(name, fn) {
|
|
77
|
+
queries = [];
|
|
78
|
+
idea = { id: '77', title: 'a thought', status: 'open', captured_by: '9', grade: 'quick', template_key: null, field_values: null, completeness_score: null };
|
|
79
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
80
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- the author gate: the whole point of the mark ------------------------
|
|
84
|
+
|
|
85
|
+
await test('the AUTHOR can mark their quick idea as a spark', async () => {
|
|
86
|
+
const r = await call(postSpark);
|
|
87
|
+
assert.equal(r.status, 200);
|
|
88
|
+
assert.equal(r.payload.sparked, true);
|
|
89
|
+
assert.equal(r.payload.unchanged, false);
|
|
90
|
+
assert.equal(r.payload.idea.grade, 'spark', 'the row comes back carrying its new grade');
|
|
91
|
+
const w = gradeWrites();
|
|
92
|
+
assert.equal(w.length, 1);
|
|
93
|
+
assert.equal(w[0].params[1], 'spark');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('a NON-author is a 403 and nothing is written — the consent is not theirs to give', async () => {
|
|
97
|
+
const r = await call(postSpark, { builderId: '12' });
|
|
98
|
+
assert.equal(r.status, 403);
|
|
99
|
+
assert.equal(r.payload.error, 'not_your_idea');
|
|
100
|
+
assert.equal(gradeWrites().length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('an idea with NO author cannot be sparked by anyone (no consent to read)', async () => {
|
|
104
|
+
idea.captured_by = null;
|
|
105
|
+
const r = await call(postSpark);
|
|
106
|
+
assert.equal(r.status, 403);
|
|
107
|
+
assert.equal(r.payload.error, 'not_your_idea');
|
|
108
|
+
assert.equal(gradeWrites().length, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('a non-author cannot WITHDRAW the mark either', async () => {
|
|
112
|
+
idea.grade = 'spark';
|
|
113
|
+
const r = await call(deleteSpark, { builderId: '12' });
|
|
114
|
+
assert.equal(r.status, 403);
|
|
115
|
+
assert.equal(gradeWrites().length, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- reversible ----------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
await test('the mark is REVERSIBLE: DELETE puts the idea back to quick', async () => {
|
|
121
|
+
idea.grade = 'spark';
|
|
122
|
+
const r = await call(deleteSpark);
|
|
123
|
+
assert.equal(r.status, 200);
|
|
124
|
+
assert.equal(r.payload.sparked, false);
|
|
125
|
+
assert.equal(r.payload.unchanged, false);
|
|
126
|
+
assert.equal(r.payload.idea.grade, 'quick');
|
|
127
|
+
const w = gradeWrites();
|
|
128
|
+
assert.equal(w.length, 1);
|
|
129
|
+
assert.equal(w[0].params[1], 'quick', 'un-sparking writes an EXPLICIT quick row (R140: it reads as quick either way)');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('both directions are idempotent and report `unchanged` rather than erroring', async () => {
|
|
133
|
+
idea.grade = 'spark';
|
|
134
|
+
const again = await call(postSpark);
|
|
135
|
+
assert.equal(again.status, 200);
|
|
136
|
+
assert.equal(again.payload.unchanged, true);
|
|
137
|
+
assert.equal(gradeWrites().length, 0, 'no write for a no-op');
|
|
138
|
+
|
|
139
|
+
queries = [];
|
|
140
|
+
idea.grade = 'quick';
|
|
141
|
+
const off = await call(deleteSpark);
|
|
142
|
+
assert.equal(off.status, 200);
|
|
143
|
+
assert.equal(off.payload.sparked, false);
|
|
144
|
+
assert.equal(off.payload.unchanged, true);
|
|
145
|
+
assert.equal(gradeWrites().length, 0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- distinct from a FULL idea ------------------------------------------
|
|
149
|
+
|
|
150
|
+
await test('a FULL idea is refused 409 — and its field_values are never touched', async () => {
|
|
151
|
+
const fields = { purpose: 'x'.repeat(60), big_picture: 'y'.repeat(60) };
|
|
152
|
+
idea.grade = 'full';
|
|
153
|
+
idea.field_values = fields;
|
|
154
|
+
idea.completeness_score = 80;
|
|
155
|
+
const r = await call(postSpark);
|
|
156
|
+
assert.equal(r.status, 409);
|
|
157
|
+
assert.equal(r.payload.error, 'not_a_quick_idea');
|
|
158
|
+
assert.equal(r.payload.grade, 'full');
|
|
159
|
+
assert.equal(gradeWrites().length, 0, 'a spark write would have WIPED the five fields');
|
|
160
|
+
assert.deepEqual(idea.field_values, fields);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await test('un-sparking a FULL idea is refused too — it is not a withdrawn spark', async () => {
|
|
164
|
+
idea.grade = 'full';
|
|
165
|
+
const r = await call(deleteSpark);
|
|
166
|
+
assert.equal(r.status, 409);
|
|
167
|
+
assert.equal(r.payload.error, 'not_a_quick_idea');
|
|
168
|
+
assert.equal(gradeWrites().length, 0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- only an OPEN idea awaits development -------------------------------
|
|
172
|
+
|
|
173
|
+
await test('a promoted idea cannot be newly marked — the spark queue must stay pickable', async () => {
|
|
174
|
+
idea.status = 'promoted';
|
|
175
|
+
const r = await call(postSpark);
|
|
176
|
+
assert.equal(r.status, 409);
|
|
177
|
+
assert.equal(r.payload.error, 'idea_not_open');
|
|
178
|
+
assert.equal(r.payload.status, 'promoted');
|
|
179
|
+
assert.equal(gradeWrites().length, 0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('but a stale mark can always be WITHDRAWN, whatever the status', async () => {
|
|
183
|
+
idea.status = 'discarded';
|
|
184
|
+
idea.grade = 'spark';
|
|
185
|
+
const r = await call(deleteSpark);
|
|
186
|
+
assert.equal(r.status, 200);
|
|
187
|
+
assert.equal(r.payload.idea.grade, 'quick');
|
|
188
|
+
assert.equal(gradeWrites().length, 1, 'housekeeping a dead idea\'s mark is never blocked');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// --- plumbing -----------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
await test('a missing idea is a 404, not a 500', async () => {
|
|
194
|
+
idea = null;
|
|
195
|
+
const r = await call(postSpark);
|
|
196
|
+
assert.equal(r.status, 404);
|
|
197
|
+
assert.equal(r.payload.error, 'idea_not_found');
|
|
198
|
+
assert.equal(gradeWrites().length, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// --- the CLI's message layer, against the REAL envelope -----------------
|
|
202
|
+
//
|
|
203
|
+
// The stub res.fail above spreads its extras onto the payload for terseness (the
|
|
204
|
+
// idea_full_capture.mjs idiom). Production does NOT: ADR 0116's envelope nests
|
|
205
|
+
// them under error.details. So the CLI's explain() is checked against the real
|
|
206
|
+
// builder, not the stub — that mismatch is a silent "grade=undefined" in an
|
|
207
|
+
// ideator's terminal, which no route-level assertion above would catch.
|
|
208
|
+
const { explain } = require('../scripts/gds/spark.js');
|
|
209
|
+
const { envelope } = require('../src/bongos/api-errors.js');
|
|
210
|
+
|
|
211
|
+
await test('the CLI reads each refusal off the REAL envelope shape', async () => {
|
|
212
|
+
const full = explain(409, envelope('not_a_quick_idea', undefined, { grade: 'full' }));
|
|
213
|
+
assert.match(full, /already a Full Idea \(grade=full\)/);
|
|
214
|
+
assert.doesNotMatch(full, /undefined/);
|
|
215
|
+
|
|
216
|
+
const closed = explain(409, envelope('idea_not_open', undefined, { status: 'promoted' }));
|
|
217
|
+
assert.match(closed, /is promoted, so it is not awaiting development/);
|
|
218
|
+
assert.doesNotMatch(closed, /undefined/);
|
|
219
|
+
|
|
220
|
+
assert.match(explain(403, envelope('not_your_idea')), /not yours to mark/);
|
|
221
|
+
assert.match(explain(404, envelope('idea_not_found')), /No idea with that id/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('an UNMAPPED server refusal is shown raw, never swallowed', async () => {
|
|
225
|
+
const out = explain(409, envelope('some_future_rule', undefined, { hint: 'x' }));
|
|
226
|
+
assert.match(out, /spark failed \(409\)/);
|
|
227
|
+
assert.match(out, /some_future_rule/, 'a new server-side rule must stay visible');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
console.log(`\nidea_spark_mark: ${passed} passed, ${failed} failed`);
|
|
231
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// tests/idea_spark_queue.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002921 (BV1.R145, goal 1000062) — the spark queue.
|
|
4
|
+
//
|
|
5
|
+
// The done-when, as assertions: the GET returns OPEN sparks NOT YET DEVELOPED,
|
|
6
|
+
// newest first; a DEVELOPED spark leaves the queue; and the queue is readable by
|
|
7
|
+
// ANY builder (a Xenos included — it is a pickup surface, and the author's R144
|
|
8
|
+
// mark is the consent).
|
|
9
|
+
//
|
|
10
|
+
// It also pins two things that are load-bearing but invisible in a passing app:
|
|
11
|
+
// the DECLARATION ORDER against `GET /inbox/:id` (which would otherwise capture
|
|
12
|
+
// the literal "sparks" and 400 the whole queue), and the fact that the count and
|
|
13
|
+
// the list share one predicate — a pill that disagrees with its rows is the
|
|
14
|
+
// classic version of this bug.
|
|
15
|
+
//
|
|
16
|
+
// DB-free the idea_full_capture.mjs way: pool.query patched on the doorway before
|
|
17
|
+
// the modules load, so the REAL SQL is captured and the REAL handler runs.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/idea_spark_queue.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const api = require('../src/module-api.js');
|
|
26
|
+
|
|
27
|
+
let queries = [];
|
|
28
|
+
let listRows = [];
|
|
29
|
+
let countN = 0;
|
|
30
|
+
|
|
31
|
+
api.pool.query = async (sql, params = []) => {
|
|
32
|
+
queries.push({ sql, params });
|
|
33
|
+
if (/COUNT\(\*\)/i.test(sql)) return { rows: [{ n: countN }] };
|
|
34
|
+
return { rows: listRows };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
38
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
39
|
+
const router = routesFactory({});
|
|
40
|
+
|
|
41
|
+
const mounted = router.stack.filter((l) => l.route)
|
|
42
|
+
.map((l) => `${Object.keys(l.route.methods)[0].toUpperCase()} ${l.route.path}`);
|
|
43
|
+
|
|
44
|
+
function handlerFor(pathStr, method) {
|
|
45
|
+
const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
|
|
46
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
|
|
47
|
+
const stack = layer.route.stack.map((s) => s.handle);
|
|
48
|
+
return stack[stack.length - 1];
|
|
49
|
+
}
|
|
50
|
+
const getSparks = handlerFor('/inbox/sparks', 'get');
|
|
51
|
+
|
|
52
|
+
function call({ query = {}, rank = 'metic' } = {}) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const req = { params: {}, body: {}, query, builder: { id: '9', rank } };
|
|
55
|
+
const res = {
|
|
56
|
+
statusCode: 200,
|
|
57
|
+
status(c) { this.statusCode = c; return this; },
|
|
58
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
59
|
+
fail(code, status, extra) {
|
|
60
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
61
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
Promise.resolve(getSparks(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const listSql = () => queries.find((q) => !/COUNT\(\*\)/i.test(q.sql));
|
|
69
|
+
const countSql = () => queries.find((q) => /COUNT\(\*\)/i.test(q.sql));
|
|
70
|
+
|
|
71
|
+
let passed = 0;
|
|
72
|
+
let failed = 0;
|
|
73
|
+
async function test(name, fn) {
|
|
74
|
+
queries = [];
|
|
75
|
+
listRows = [];
|
|
76
|
+
countN = 0;
|
|
77
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
78
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- declaration order: the bug that would 400 the whole queue -----------
|
|
82
|
+
|
|
83
|
+
await test('GET /inbox/sparks is declared BEFORE GET /inbox/:id', () => {
|
|
84
|
+
const iSparks = mounted.indexOf('GET /inbox/sparks');
|
|
85
|
+
const iId = mounted.indexOf('GET /inbox/:id');
|
|
86
|
+
assert.ok(iSparks >= 0, 'the spark queue route is mounted');
|
|
87
|
+
assert.ok(iId >= 0, 'the by-id read is mounted');
|
|
88
|
+
assert.ok(
|
|
89
|
+
iSparks < iId,
|
|
90
|
+
`declaration order is load-bearing: with :id first, "/inbox/sparks" binds id="sparks" and parseId 400s the queue (sparks@${iSparks}, :id@${iId})`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- the predicate: open, undeveloped sparks, newest first ---------------
|
|
95
|
+
|
|
96
|
+
await test('the queue asks for grade=spark AND status=open — nothing else', async () => {
|
|
97
|
+
const r = await call();
|
|
98
|
+
assert.equal(r.status, 200);
|
|
99
|
+
const sql = listSql().sql;
|
|
100
|
+
assert.match(sql, /g\.grade = 'spark'/, 'only sparks');
|
|
101
|
+
assert.match(sql, /i\.status = 'open'/, 'only open ideas');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await test('a DEVELOPED spark leaves the queue by GRADE, with no `developed` flag', async () => {
|
|
105
|
+
await call();
|
|
106
|
+
const sql = listSql().sql;
|
|
107
|
+
// R146 develops in place and flips the grade spark→full, so grade='spark' IS
|
|
108
|
+
// "not yet developed". A second source of truth could disagree with the grade.
|
|
109
|
+
assert.doesNotMatch(sql, /developed/i, 'no separate developed column/flag');
|
|
110
|
+
assert.doesNotMatch(sql, /promoted_to_task_id\s*IS\s*NULL/i, 'exit is the grade flip, not a promote link');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await test('newest first, with a stable tiebreak', async () => {
|
|
114
|
+
await call();
|
|
115
|
+
assert.match(listSql().sql, /ORDER BY i\.captured_at DESC, i\.id DESC/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('an INNER join from ideas_grades — a spark is never implicit', async () => {
|
|
119
|
+
await call();
|
|
120
|
+
const sql = listSql().sql;
|
|
121
|
+
// Inverted from every other idea read on purpose: absence of a grade row means
|
|
122
|
+
// 'quick', so a spark ALWAYS has a real row, and starting from ideas_grades
|
|
123
|
+
// lets ideas_grades_grade_score_idx drive the scan.
|
|
124
|
+
assert.match(sql, /FROM ideas_grades g/, 'driven by the graded table');
|
|
125
|
+
assert.match(sql, /JOIN idea_inbox i ON i\.id = g\.idea_id/);
|
|
126
|
+
assert.doesNotMatch(sql, /LEFT JOIN ideas_grades/, 'not the COALESCE-to-quick read shape');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await test('the author is named — attribution is the point of a pickup surface', async () => {
|
|
130
|
+
listRows = [{ id: '5', title: 'a spark', captured_by: '3', grade: 'spark', filed_by_login: 'someone' }];
|
|
131
|
+
const r = await call();
|
|
132
|
+
assert.match(listSql().sql, /github_login/);
|
|
133
|
+
assert.equal(r.payload.sparks[0].filed_by_login, 'someone');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// --- the count cannot disagree with the list ----------------------------
|
|
137
|
+
|
|
138
|
+
await test('count and list share ONE predicate', async () => {
|
|
139
|
+
countN = 7;
|
|
140
|
+
const r = await call();
|
|
141
|
+
assert.equal(r.payload.count, 7);
|
|
142
|
+
const l = listSql().sql;
|
|
143
|
+
const c = countSql().sql;
|
|
144
|
+
for (const clause of [/g\.grade = 'spark'/, /i\.status = 'open'/]) {
|
|
145
|
+
assert.match(l, clause);
|
|
146
|
+
assert.match(c, clause, 'the pill must not claim sparks the list will not show');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// --- readable by ANY builder -------------------------------------------
|
|
151
|
+
|
|
152
|
+
await test('a XENOS can read the queue — the R144 mark is the consent', async () => {
|
|
153
|
+
listRows = [{ id: '5', title: 'a spark', grade: 'spark' }];
|
|
154
|
+
const r = await call({ rank: 'xenos' });
|
|
155
|
+
assert.equal(r.status, 200, 'no rank gate on a pickup surface');
|
|
156
|
+
assert.equal(r.payload.sparks.length, 1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await test('but the skill-friction visibility gate still applies, to BOTH halves', async () => {
|
|
160
|
+
await call({ rank: 'xenos' });
|
|
161
|
+
const l = listSql();
|
|
162
|
+
const c = countSql();
|
|
163
|
+
assert.match(l.sql, /i\.title NOT LIKE/, 'a Xenos does not see routine-filed friction rows');
|
|
164
|
+
assert.match(c.sql, /i\.title NOT LIKE/, 'nor are they counted for them');
|
|
165
|
+
assert.ok(l.params.some((p) => typeof p === 'string' && p.endsWith('%')));
|
|
166
|
+
|
|
167
|
+
queries = [];
|
|
168
|
+
await call({ rank: 'archon' });
|
|
169
|
+
assert.doesNotMatch(listSql().sql, /i\.title NOT LIKE/, 'a trusted rank sees everything');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- limit -------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
await test('limit defaults to 50 and is clamped to 1..100', async () => {
|
|
175
|
+
await call();
|
|
176
|
+
assert.equal(listSql().params.at(-1), 50, 'default');
|
|
177
|
+
|
|
178
|
+
for (const [given, want] of [['10', 10], ['0', 50], ['-5', 50], ['1000', 50], ['abc', 50], ['100', 100]]) {
|
|
179
|
+
queries = [];
|
|
180
|
+
await call({ query: { limit: given } });
|
|
181
|
+
assert.equal(listSql().params.at(-1), want, `limit=${given}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('the count query takes no limit — it counts the whole queue', async () => {
|
|
186
|
+
await call({ query: { limit: '5' } });
|
|
187
|
+
assert.equal(countSql().params.length, 0, 'a trusted viewer adds no filter param, and the count never takes the limit');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// --- the CLI's rendering of the queue ------------------------------------
|
|
191
|
+
|
|
192
|
+
const { renderQueue } = require('../scripts/gds/spark.js');
|
|
193
|
+
|
|
194
|
+
await test('the empty queue reads as an ANSWER, not a blank screen', () => {
|
|
195
|
+
const out = renderQueue({ sparks: [], count: 0 });
|
|
196
|
+
assert.match(out, /No sparks are awaiting development/);
|
|
197
|
+
assert.match(out, /mark one of your own/, 'it says what to do next');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('each row links to the HALL, never a bare #NNN', () => {
|
|
201
|
+
const out = renderQueue({
|
|
202
|
+
sparks: [{ id: '812', title: 'a thought', filed_by_login: 'someone', captured_at: '2026-08-20T10:11:12Z' }],
|
|
203
|
+
count: 1,
|
|
204
|
+
});
|
|
205
|
+
// A bare "#NNN" autolinks to a GitHub issue and 404s — our ids are not
|
|
206
|
+
// GitHub's. The hall deep-link resolver form is /builders/#/idea/N.
|
|
207
|
+
assert.match(out, /\/builders\/#\/idea\/812/);
|
|
208
|
+
assert.doesNotMatch(out, /(^|[^/])#812\b/, 'no bare ref');
|
|
209
|
+
assert.match(out, /@someone/, 'the author is named');
|
|
210
|
+
assert.match(out, /2026-08-20/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test('a missing author degrades to prose, not "@undefined"', () => {
|
|
214
|
+
const out = renderQueue({ sparks: [{ id: '1', title: 't', filed_by_login: null }], count: 1 });
|
|
215
|
+
assert.match(out, /an unnamed builder/);
|
|
216
|
+
assert.doesNotMatch(out, /undefined|null/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('the header distinguishes "showing N" from the true total', () => {
|
|
220
|
+
assert.match(renderQueue({ sparks: [{ id: '1', title: 't' }], count: 5 }), /5 sparks awaiting development \(showing 1\)/);
|
|
221
|
+
assert.match(renderQueue({ sparks: [{ id: '1', title: 't' }], count: 1 }), /^1 spark awaiting development:/m, 'singular, no "showing"');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(`\nidea_spark_queue: ${passed} passed, ${failed} failed`);
|
|
225
|
+
if (failed > 0) process.exit(1);
|