@bongos/core 1.19.295 → 1.19.297
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 +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +124 -3
- package/docs/api-reference.md +4 -2
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/ideas/inbox.js +174 -2
- package/modules/ideas/migrations/ideas_005_grade_developed_by.sql +85 -0
- package/modules/ideas/routes/inbox.js +191 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spark.js +171 -5
- package/src/module-api.js +5 -1
- package/tests/idea_develop_spark.mjs +399 -0
- package/tests/idea_spark_queue.mjs +233 -0
|
@@ -0,0 +1,233 @@
|
|
|
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, not by a `developed` filter', 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
|
+
//
|
|
110
|
+
// The invariant is about the PREDICATE, so only the predicate is asserted.
|
|
111
|
+
// R146 later added developed_by/developed_at to the SELECT list (attribution
|
|
112
|
+
// rides along on every idea read) — that is not a filter and must not trip this.
|
|
113
|
+
const predicate = sql.slice(sql.search(/\bWHERE\b/i));
|
|
114
|
+
assert.doesNotMatch(predicate, /developed/i, 'nothing in the WHERE keys off a developed flag');
|
|
115
|
+
assert.doesNotMatch(predicate, /promoted_to_task_id\s*IS\s*NULL/i, 'exit is the grade flip, not a promote link');
|
|
116
|
+
// And the positive half, so the two tasks' expectations are explicit rather
|
|
117
|
+
// than merely non-conflicting: the attribution IS returned.
|
|
118
|
+
assert.match(sql, /g\.developed_by/, 'who developed it rides along on the read (R146)');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await test('newest first, with a stable tiebreak', async () => {
|
|
122
|
+
await call();
|
|
123
|
+
assert.match(listSql().sql, /ORDER BY i\.captured_at DESC, i\.id DESC/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('an INNER join from ideas_grades — a spark is never implicit', async () => {
|
|
127
|
+
await call();
|
|
128
|
+
const sql = listSql().sql;
|
|
129
|
+
// Inverted from every other idea read on purpose: absence of a grade row means
|
|
130
|
+
// 'quick', so a spark ALWAYS has a real row, and starting from ideas_grades
|
|
131
|
+
// lets ideas_grades_grade_score_idx drive the scan.
|
|
132
|
+
assert.match(sql, /FROM ideas_grades g/, 'driven by the graded table');
|
|
133
|
+
assert.match(sql, /JOIN idea_inbox i ON i\.id = g\.idea_id/);
|
|
134
|
+
assert.doesNotMatch(sql, /LEFT JOIN ideas_grades/, 'not the COALESCE-to-quick read shape');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('the author is named — attribution is the point of a pickup surface', async () => {
|
|
138
|
+
listRows = [{ id: '5', title: 'a spark', captured_by: '3', grade: 'spark', filed_by_login: 'someone' }];
|
|
139
|
+
const r = await call();
|
|
140
|
+
assert.match(listSql().sql, /github_login/);
|
|
141
|
+
assert.equal(r.payload.sparks[0].filed_by_login, 'someone');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- the count cannot disagree with the list ----------------------------
|
|
145
|
+
|
|
146
|
+
await test('count and list share ONE predicate', async () => {
|
|
147
|
+
countN = 7;
|
|
148
|
+
const r = await call();
|
|
149
|
+
assert.equal(r.payload.count, 7);
|
|
150
|
+
const l = listSql().sql;
|
|
151
|
+
const c = countSql().sql;
|
|
152
|
+
for (const clause of [/g\.grade = 'spark'/, /i\.status = 'open'/]) {
|
|
153
|
+
assert.match(l, clause);
|
|
154
|
+
assert.match(c, clause, 'the pill must not claim sparks the list will not show');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- readable by ANY builder -------------------------------------------
|
|
159
|
+
|
|
160
|
+
await test('a XENOS can read the queue — the R144 mark is the consent', async () => {
|
|
161
|
+
listRows = [{ id: '5', title: 'a spark', grade: 'spark' }];
|
|
162
|
+
const r = await call({ rank: 'xenos' });
|
|
163
|
+
assert.equal(r.status, 200, 'no rank gate on a pickup surface');
|
|
164
|
+
assert.equal(r.payload.sparks.length, 1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('but the skill-friction visibility gate still applies, to BOTH halves', async () => {
|
|
168
|
+
await call({ rank: 'xenos' });
|
|
169
|
+
const l = listSql();
|
|
170
|
+
const c = countSql();
|
|
171
|
+
assert.match(l.sql, /i\.title NOT LIKE/, 'a Xenos does not see routine-filed friction rows');
|
|
172
|
+
assert.match(c.sql, /i\.title NOT LIKE/, 'nor are they counted for them');
|
|
173
|
+
assert.ok(l.params.some((p) => typeof p === 'string' && p.endsWith('%')));
|
|
174
|
+
|
|
175
|
+
queries = [];
|
|
176
|
+
await call({ rank: 'archon' });
|
|
177
|
+
assert.doesNotMatch(listSql().sql, /i\.title NOT LIKE/, 'a trusted rank sees everything');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// --- limit -------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
await test('limit defaults to 50 and is clamped to 1..100', async () => {
|
|
183
|
+
await call();
|
|
184
|
+
assert.equal(listSql().params.at(-1), 50, 'default');
|
|
185
|
+
|
|
186
|
+
for (const [given, want] of [['10', 10], ['0', 50], ['-5', 50], ['1000', 50], ['abc', 50], ['100', 100]]) {
|
|
187
|
+
queries = [];
|
|
188
|
+
await call({ query: { limit: given } });
|
|
189
|
+
assert.equal(listSql().params.at(-1), want, `limit=${given}`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('the count query takes no limit — it counts the whole queue', async () => {
|
|
194
|
+
await call({ query: { limit: '5' } });
|
|
195
|
+
assert.equal(countSql().params.length, 0, 'a trusted viewer adds no filter param, and the count never takes the limit');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// --- the CLI's rendering of the queue ------------------------------------
|
|
199
|
+
|
|
200
|
+
const { renderQueue } = require('../scripts/gds/spark.js');
|
|
201
|
+
|
|
202
|
+
await test('the empty queue reads as an ANSWER, not a blank screen', () => {
|
|
203
|
+
const out = renderQueue({ sparks: [], count: 0 });
|
|
204
|
+
assert.match(out, /No sparks are awaiting development/);
|
|
205
|
+
assert.match(out, /mark one of your own/, 'it says what to do next');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await test('each row links to the HALL, never a bare #NNN', () => {
|
|
209
|
+
const out = renderQueue({
|
|
210
|
+
sparks: [{ id: '812', title: 'a thought', filed_by_login: 'someone', captured_at: '2026-08-20T10:11:12Z' }],
|
|
211
|
+
count: 1,
|
|
212
|
+
});
|
|
213
|
+
// A bare "#NNN" autolinks to a GitHub issue and 404s — our ids are not
|
|
214
|
+
// GitHub's. The hall deep-link resolver form is /builders/#/idea/N.
|
|
215
|
+
assert.match(out, /\/builders\/#\/idea\/812/);
|
|
216
|
+
assert.doesNotMatch(out, /(^|[^/])#812\b/, 'no bare ref');
|
|
217
|
+
assert.match(out, /@someone/, 'the author is named');
|
|
218
|
+
assert.match(out, /2026-08-20/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test('a missing author degrades to prose, not "@undefined"', () => {
|
|
222
|
+
const out = renderQueue({ sparks: [{ id: '1', title: 't', filed_by_login: null }], count: 1 });
|
|
223
|
+
assert.match(out, /an unnamed builder/);
|
|
224
|
+
assert.doesNotMatch(out, /undefined|null/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('the header distinguishes "showing N" from the true total', () => {
|
|
228
|
+
assert.match(renderQueue({ sparks: [{ id: '1', title: 't' }], count: 5 }), /5 sparks awaiting development \(showing 1\)/);
|
|
229
|
+
assert.match(renderQueue({ sparks: [{ id: '1', title: 't' }], count: 1 }), /^1 spark awaiting development:/m, 'singular, no "showing"');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
console.log(`\nidea_spark_queue: ${passed} passed, ${failed} failed`);
|
|
233
|
+
if (failed > 0) process.exit(1);
|