@bongos/core 1.19.296 → 1.19.298
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 +47 -27
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0185-spark-handoff-credit-split.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -2
- package/docs/api-reference.md +3 -2
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/economy/credits.js +156 -31
- package/modules/ideas/inbox.js +86 -2
- package/modules/ideas/migrations/ideas_005_grade_developed_by.sql +85 -0
- package/modules/ideas/routes/inbox.js +175 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spark.js +119 -5
- package/src/module-api.js +5 -1
- package/tests/idea_credit_split.mjs +224 -0
- package/tests/idea_develop_spark.mjs +436 -0
- package/tests/idea_full_pass_credit.mjs +50 -18
- package/tests/idea_spark_queue.mjs +11 -3
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// tests/idea_develop_spark.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002922 (BV1.R146, goal 1000062) — develop a Spark into a Full Idea,
|
|
4
|
+
// preserving attribution.
|
|
5
|
+
//
|
|
6
|
+
// The done-when, as assertions: the row keeps its ID and its ORIGINAL AUTHOR; a
|
|
7
|
+
// DISTINCT DEVELOPER is recorded; references to the idea never break (nothing
|
|
8
|
+
// writes idea_inbox); and a spark can be developed ONLY ONCE — enforced in the
|
|
9
|
+
// UPDATE predicate, not by a read-then-write that loses the race.
|
|
10
|
+
//
|
|
11
|
+
// It also pins the scope boundary that matters for money: this path must NOT pay
|
|
12
|
+
// the stream-2 "Full Idea passed" credit. awardIdeaFullPassCredit is idempotent
|
|
13
|
+
// per (builder, idea), so paying the DEVELOPER here would not block a later
|
|
14
|
+
// payment to the AUTHOR — one idea would pay twice. R147 owns the split.
|
|
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_develop_spark.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 idea = null;
|
|
29
|
+
let developRows = []; // what the guarded UPDATE returns ([] = guard fired)
|
|
30
|
+
|
|
31
|
+
api.pool.query = async (sql, params = []) => {
|
|
32
|
+
queries.push({ sql, params });
|
|
33
|
+
if (/UPDATE ideas_grades/i.test(sql)) return { rows: developRows };
|
|
34
|
+
if (/FROM idea_inbox/i.test(sql)) return { rows: idea ? [idea] : [] };
|
|
35
|
+
return { rows: [] };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Which ports the handler asked the kernel for — the credit boundary is asserted
|
|
39
|
+
// by the ABSENCE of 'reward' here, which is stronger than checking for no INSERT.
|
|
40
|
+
let portsResolved = [];
|
|
41
|
+
const realResolveOptional = api.resolveOptional;
|
|
42
|
+
api.resolveOptional = (name) => { portsResolved.push(name); return undefined; };
|
|
43
|
+
|
|
44
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
45
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
46
|
+
const router = routesFactory({});
|
|
47
|
+
|
|
48
|
+
function handlerFor(pathStr, method) {
|
|
49
|
+
const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
|
|
50
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
|
|
51
|
+
const stack = layer.route.stack.map((s) => s.handle);
|
|
52
|
+
return stack[stack.length - 1];
|
|
53
|
+
}
|
|
54
|
+
const postDevelop = handlerFor('/inbox/:id/develop', 'post');
|
|
55
|
+
|
|
56
|
+
const FULL = Object.fromEntries(
|
|
57
|
+
BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
function call({ body = { field_values: FULL }, builderId = '42' } = {}) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const req = { params: { id: '77' }, body, query: {}, builder: { id: builderId, rank: 'xenos' } };
|
|
63
|
+
const res = {
|
|
64
|
+
statusCode: 200,
|
|
65
|
+
status(c) { this.statusCode = c; return this; },
|
|
66
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
67
|
+
// Production nests res.fail's third argument under error.details (ADR 0116).
|
|
68
|
+
// Kept BOTH here: `details` verbatim (so an ARRAY of field errors survives —
|
|
69
|
+
// spreading one yields {0:…} and silently loses the shape), plus the object
|
|
70
|
+
// spread the sibling idea tests read.
|
|
71
|
+
fail(code, status, extra) {
|
|
72
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
73
|
+
const spread = extra && !Array.isArray(extra) ? extra : {};
|
|
74
|
+
resolve({ status: s, payload: { error: code, details: extra, ...spread } });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
Promise.resolve(postDevelop(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const updateQ = () => queries.find((q) => /UPDATE ideas_grades/i.test(q.sql));
|
|
82
|
+
const writes = () => queries.filter((q) => /^\s*(UPDATE|INSERT|DELETE)/i.test(q.sql));
|
|
83
|
+
|
|
84
|
+
let passed = 0;
|
|
85
|
+
let failed = 0;
|
|
86
|
+
const basePoolQuery = async (sql, params = []) => {
|
|
87
|
+
queries.push({ sql, params });
|
|
88
|
+
if (/UPDATE ideas_grades/i.test(sql)) return { rows: developRows };
|
|
89
|
+
if (/FROM idea_inbox/i.test(sql)) return { rows: idea ? [idea] : [] };
|
|
90
|
+
return { rows: [] };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
async function test(name, fn) {
|
|
94
|
+
queries = [];
|
|
95
|
+
portsResolved = [];
|
|
96
|
+
// Restored every test: cases below swap in a two-phase pool stub (a triager
|
|
97
|
+
// closing the idea mid-request) and reward/government port stubs, and none of
|
|
98
|
+
// them may leak into the next test.
|
|
99
|
+
api.pool.query = basePoolQuery;
|
|
100
|
+
api.resolveOptional = (name) => { portsResolved.push(name); return undefined; };
|
|
101
|
+
// The author is builder 9; the developer in call() defaults to 42.
|
|
102
|
+
idea = {
|
|
103
|
+
id: '77', title: 'a spark', body_md: 'the thought', status: 'open',
|
|
104
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z',
|
|
105
|
+
grade: 'spark', template_key: null, field_values: null,
|
|
106
|
+
completeness_score: null, developed_by: null, developed_at: null,
|
|
107
|
+
};
|
|
108
|
+
developRows = [{
|
|
109
|
+
idea_id: '77', grade: 'full', template_key: 'base-v1', field_values: FULL,
|
|
110
|
+
completeness_score: 100, developed_by: '42', developed_at: '2026-08-22T12:00:00Z',
|
|
111
|
+
}];
|
|
112
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
113
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- the happy path, and what it preserves ------------------------------
|
|
117
|
+
|
|
118
|
+
await test('ANY builder can develop a spark — cross-builder pickup is the point', async () => {
|
|
119
|
+
const r = await call();
|
|
120
|
+
assert.equal(r.status, 200);
|
|
121
|
+
assert.equal(r.payload.developed, true);
|
|
122
|
+
assert.equal(r.payload.idea.grade, 'full');
|
|
123
|
+
assert.equal(r.payload.idea.completeness_score, 100);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('the row keeps its ID and its ORIGINAL author; only the grade moves', async () => {
|
|
127
|
+
const r = await call();
|
|
128
|
+
assert.equal(r.payload.idea.id, '77', 'same id — references never break');
|
|
129
|
+
assert.equal(r.payload.idea.captured_by, '9', 'the author is NOT overwritten');
|
|
130
|
+
assert.equal(r.payload.idea.captured_at, '2026-08-01T00:00:00Z', 'nor the capture time');
|
|
131
|
+
// The strongest form of "references never break": idea_inbox is never WRITTEN.
|
|
132
|
+
// Matched on the write verb + table, not the bare table name — the status guard
|
|
133
|
+
// legitimately READS idea_inbox in an EXISTS subquery inside the grade UPDATE.
|
|
134
|
+
assert.ok(
|
|
135
|
+
!queries.some((q) => /(UPDATE\s+idea_inbox|INSERT\s+INTO\s+idea_inbox|DELETE\s+FROM\s+idea_inbox)/i.test(q.sql)),
|
|
136
|
+
'development touches ideas_grades only — no new row, no idea_inbox mutation',
|
|
137
|
+
);
|
|
138
|
+
// And the write really is to the grade table.
|
|
139
|
+
assert.match(updateQ().sql, /^\s*UPDATE ideas_grades/i);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('a DISTINCT developer is recorded, and both parties are named in the response', async () => {
|
|
143
|
+
const r = await call({ builderId: '42' });
|
|
144
|
+
assert.equal(r.payload.idea.developed_by, '42');
|
|
145
|
+
assert.equal(r.payload.developer_id, '42');
|
|
146
|
+
assert.equal(r.payload.author_id, '9');
|
|
147
|
+
assert.equal(r.payload.self_developed, false);
|
|
148
|
+
assert.equal(updateQ().params.at(-1), '42', 'the developer is the AUTHENTICATED builder, not a body field');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('self-development is legal and reported as such', async () => {
|
|
152
|
+
developRows[0].developed_by = '9';
|
|
153
|
+
const r = await call({ builderId: '9' });
|
|
154
|
+
assert.equal(r.status, 200, 'an author may develop their own spark');
|
|
155
|
+
assert.equal(r.payload.self_developed, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('the developer cannot be spoofed: the body allowlist REFUSES the field', async () => {
|
|
159
|
+
// The write takes req.builder.id (asserted above), and the validator is a
|
|
160
|
+
// strict allowlist on top of that — so an attempt to name a developer, or to
|
|
161
|
+
// rewrite the author, is refused by NAME before the handler reads anything.
|
|
162
|
+
for (const spoof of ['developed_by', 'developer_id', 'captured_by']) {
|
|
163
|
+
queries = [];
|
|
164
|
+
const r = await call({ body: { field_values: FULL, [spoof]: '1' } });
|
|
165
|
+
assert.equal(r.status, 400, `${spoof} is not an accepted body field`);
|
|
166
|
+
assert.equal(r.payload.error, 'validation_failed');
|
|
167
|
+
assert.deepEqual(r.payload.details, [{ field: spoof, reason: 'unknown_field' }], 'the refused field is named');
|
|
168
|
+
assert.equal(writes().length, 0);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- developed only once, and where that is actually enforced -----------
|
|
173
|
+
|
|
174
|
+
await test('"only once" lives in the UPDATE predicate, not in a read-then-write', async () => {
|
|
175
|
+
await call();
|
|
176
|
+
const sql = updateQ().sql;
|
|
177
|
+
assert.match(sql, /WHERE idea_id = \$1/);
|
|
178
|
+
assert.match(sql, /AND grade = 'spark'/, 'the guard is in the write, so a race cannot double-develop');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await test('a lost race is a 409, and the winner\'s work is not overwritten', async () => {
|
|
182
|
+
developRows = []; // the guard matched zero rows: someone developed it first
|
|
183
|
+
const r = await call();
|
|
184
|
+
assert.equal(r.status, 409);
|
|
185
|
+
assert.equal(r.payload.error, 'spark_already_developed');
|
|
186
|
+
assert.match(r.payload.reason, /their version stands/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('an ALREADY-DEVELOPED idea is refused before any write', async () => {
|
|
190
|
+
idea.grade = 'full';
|
|
191
|
+
idea.developed_by = '7';
|
|
192
|
+
const r = await call();
|
|
193
|
+
assert.equal(r.status, 409);
|
|
194
|
+
assert.equal(r.payload.error, 'not_a_spark');
|
|
195
|
+
assert.equal(r.payload.developed_by, '7', 'it says who got there first');
|
|
196
|
+
assert.equal(writes().length, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await test('a QUICK idea cannot be developed — there is no consent to develop it', async () => {
|
|
200
|
+
idea.grade = 'quick';
|
|
201
|
+
const r = await call();
|
|
202
|
+
assert.equal(r.status, 409);
|
|
203
|
+
assert.equal(r.payload.error, 'not_a_spark');
|
|
204
|
+
assert.match(r.payload.reason, /author has to mark it first/);
|
|
205
|
+
assert.equal(writes().length, 0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- a spark is not necessarily still DEVELOPABLE (grader finding, R146) -
|
|
209
|
+
|
|
210
|
+
// Triage writes idea_inbox.status and NEVER touches ideas_grades, so a discarded
|
|
211
|
+
// or promoted spark keeps grade='spark' forever. Grade alone therefore does not
|
|
212
|
+
// mean "still developable" — and developing a dead idea would open a fresh board
|
|
213
|
+
// ratification window for content triage already killed.
|
|
214
|
+
for (const dead of ['discarded', 'promoted', 'merged']) {
|
|
215
|
+
await test(`a ${dead} spark cannot be developed, even though its grade is still 'spark'`, async () => {
|
|
216
|
+
idea.status = dead;
|
|
217
|
+
const r = await call();
|
|
218
|
+
assert.equal(r.status, 409);
|
|
219
|
+
assert.equal(r.payload.error, 'idea_not_open');
|
|
220
|
+
assert.equal(r.payload.status, dead);
|
|
221
|
+
assert.equal(writes().length, 0, 'refused before any write');
|
|
222
|
+
assert.ok(!portsResolved.includes('government'), 'and no board window is opened for a dead idea');
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await test('the status guard is in the WRITE PREDICATE too, not just the pre-check', async () => {
|
|
227
|
+
await call();
|
|
228
|
+
const sql = updateQ().sql;
|
|
229
|
+
// A pre-check cannot stop a triager who discards the idea mid-request; the fix
|
|
230
|
+
// for a race is never a check that ran earlier.
|
|
231
|
+
assert.match(sql, /EXISTS/i);
|
|
232
|
+
assert.match(sql, /idea_inbox i WHERE i\.id = \$1 AND i\.status = 'open'/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('losing to a TRIAGER mid-request is reported as such, not as a lost race', async () => {
|
|
236
|
+
developRows = []; // the guard fired
|
|
237
|
+
idea.status = 'discarded'; // ...because triage closed it
|
|
238
|
+
// The pre-check catches this one first, which is the desired outcome; the
|
|
239
|
+
// post-write branch below covers the genuinely concurrent case.
|
|
240
|
+
const r = await call();
|
|
241
|
+
assert.equal(r.payload.error, 'idea_not_open');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await test('a spark closed BETWEEN the read and the write reports the triager, not a rival', async () => {
|
|
245
|
+
developRows = [];
|
|
246
|
+
// Open on the pre-read, closed by the time the re-read happens.
|
|
247
|
+
let reads = 0;
|
|
248
|
+
api.pool.query = async (sql, params = []) => {
|
|
249
|
+
queries.push({ sql, params });
|
|
250
|
+
if (/UPDATE ideas_grades/i.test(sql)) return { rows: [] };
|
|
251
|
+
if (/FROM idea_inbox/i.test(sql)) {
|
|
252
|
+
reads += 1;
|
|
253
|
+
return { rows: [{ ...idea, status: reads === 1 ? 'open' : 'discarded' }] };
|
|
254
|
+
}
|
|
255
|
+
return { rows: [] };
|
|
256
|
+
};
|
|
257
|
+
const r = await call();
|
|
258
|
+
assert.equal(r.status, 409);
|
|
259
|
+
assert.equal(r.payload.error, 'idea_not_open');
|
|
260
|
+
assert.equal(r.payload.status, 'discarded');
|
|
261
|
+
assert.match(r.payload.reason, /triager closed this idea while you were developing it/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- the same standard as any other Full Idea --------------------------
|
|
265
|
+
|
|
266
|
+
await test('an invalid field set is a 400 NAMING the field, and nothing is written', async () => {
|
|
267
|
+
const r = await call({ body: { field_values: { ...FULL, rogue: 'x' } } });
|
|
268
|
+
assert.equal(r.status, 400);
|
|
269
|
+
assert.equal(r.payload.error, 'invalid_field_values');
|
|
270
|
+
assert.deepEqual(r.payload.errors, [{ field: 'rogue', reason: 'unknown_field' }]);
|
|
271
|
+
assert.equal(writes().length, 0, 'a spark is not consumed by a failed attempt');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await test('a missing required field is refused the same way', async () => {
|
|
275
|
+
const { fit, ...four } = FULL;
|
|
276
|
+
const r = await call({ body: { field_values: four } });
|
|
277
|
+
assert.equal(r.status, 400);
|
|
278
|
+
assert.ok(r.payload.errors.some((e) => e.field === 'fit' && e.reason === 'missing_required'));
|
|
279
|
+
assert.equal(writes().length, 0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await test('field_values is required — an empty develop is not a developed idea', async () => {
|
|
283
|
+
const r = await call({ body: {} });
|
|
284
|
+
assert.equal(r.status, 400);
|
|
285
|
+
assert.equal(writes().length, 0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await test('a missing idea is a 404, not a 500', async () => {
|
|
289
|
+
idea = null;
|
|
290
|
+
const r = await call();
|
|
291
|
+
assert.equal(r.status, 404);
|
|
292
|
+
assert.equal(r.payload.error, 'idea_not_found');
|
|
293
|
+
assert.equal(writes().length, 0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// --- the money boundary: R147 owns the split ---------------------------
|
|
297
|
+
|
|
298
|
+
// R146 shipped this route paying NOTHING on purpose: with two people involved the
|
|
299
|
+
// recipient was ambiguous, and awardIdeaFullPassCredit's idempotency predicate is
|
|
300
|
+
// (builder_id, reason) — so guessing the developer would not have blocked a later
|
|
301
|
+
// payment to the author, and one idea would have paid twice. R147 (ADR 0185)
|
|
302
|
+
// resolved that by moving the payee decision INTO the economy module, so this
|
|
303
|
+
// route can now call it safely: it no longer chooses who earns.
|
|
304
|
+
await test('the stream-2 pass credit IS paid here now, and the route does not pick the payee', async () => {
|
|
305
|
+
let seen = null;
|
|
306
|
+
api.resolveOptional = (name) => {
|
|
307
|
+
portsResolved.push(name);
|
|
308
|
+
if (name === 'reward') {
|
|
309
|
+
return {
|
|
310
|
+
awardIdeaFullPassCredit: async (_client, args) => {
|
|
311
|
+
seen = args;
|
|
312
|
+
return { credited: 5, reason: 'paid', payees: [
|
|
313
|
+
{ builderId: '42', delta: 4, role: 'developer', booked: true },
|
|
314
|
+
{ builderId: '9', delta: 1, role: 'spark_author', booked: true },
|
|
315
|
+
] };
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
321
|
+
const r = await call();
|
|
322
|
+
assert.equal(r.status, 200);
|
|
323
|
+
assert.ok(portsResolved.includes('reward'), 'the reward port is consulted');
|
|
324
|
+
assert.equal(r.payload.idea.ideator_credit, 5, 'the total is surfaced');
|
|
325
|
+
assert.equal(r.payload.idea.ideator_credit_split.length, 2, 'and the per-payee breakdown');
|
|
326
|
+
// The route hands over the idea and the score — never a chosen recipient beyond
|
|
327
|
+
// the author fallback the economy module overrides from the DB.
|
|
328
|
+
assert.equal(seen.ideaId, 77);
|
|
329
|
+
assert.equal(seen.completenessScore, 100);
|
|
330
|
+
assert.ok(!('developedBy' in seen), 'the route does not tell economy who developed it');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await test('a credit failure does not roll back the development (fail-open)', async () => {
|
|
334
|
+
api.resolveOptional = (name) => {
|
|
335
|
+
portsResolved.push(name);
|
|
336
|
+
if (name === 'reward') return { awardIdeaFullPassCredit: async () => { throw new Error('synthetic credit outage'); } };
|
|
337
|
+
return undefined;
|
|
338
|
+
};
|
|
339
|
+
const r = await call();
|
|
340
|
+
assert.equal(r.status, 200, 'the development landed');
|
|
341
|
+
assert.equal(r.payload.developed, true);
|
|
342
|
+
assert.equal(r.payload.idea.ideator_credit, undefined);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await test('but the board window IS opened — otherwise the handoff dead-ends', async () => {
|
|
346
|
+
await call();
|
|
347
|
+
// Recipient-agnostic (an idea and a score, no builder), and without it a
|
|
348
|
+
// developed Full Idea could never reach ratification.
|
|
349
|
+
assert.ok(portsResolved.includes('government'), 'the government port is consulted');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await test('a board-window failure does not roll back the development (fail-open)', async () => {
|
|
353
|
+
api.resolveOptional = (name) => {
|
|
354
|
+
portsResolved.push(name);
|
|
355
|
+
if (name === 'government') return { openBoardWindowForIdea: async () => { throw new Error('synthetic board outage'); } };
|
|
356
|
+
return undefined;
|
|
357
|
+
};
|
|
358
|
+
const r = await call();
|
|
359
|
+
assert.equal(r.status, 200, 'the development landed');
|
|
360
|
+
assert.equal(r.payload.developed, true);
|
|
361
|
+
assert.equal(r.payload.board_window, undefined);
|
|
362
|
+
api.resolveOptional = (name) => { portsResolved.push(name); return undefined; };
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
api.resolveOptional = realResolveOptional;
|
|
366
|
+
|
|
367
|
+
// --- what the developer actually reads -----------------------------------
|
|
368
|
+
|
|
369
|
+
const cli = require('../scripts/gds/spark.js');
|
|
370
|
+
|
|
371
|
+
await test('the CLI names both parties, and never prints "undefined"', () => {
|
|
372
|
+
const out = cli.renderDeveloped({
|
|
373
|
+
idea: { id: '77', completeness_score: 88 },
|
|
374
|
+
author_id: '9', developer_id: '42', self_developed: false,
|
|
375
|
+
board_window: { item_id: '3', closes_at: '2026-08-29T00:00:00Z' },
|
|
376
|
+
});
|
|
377
|
+
assert.match(out, /completeness 88\/100/);
|
|
378
|
+
assert.match(out, /\/builders\/#\/idea\/77/, 'the hall link, never a bare ref');
|
|
379
|
+
assert.match(out, /credited to builder 9/, 'the author keeps the thought');
|
|
380
|
+
assert.match(out, /developer \(42\)/);
|
|
381
|
+
assert.match(out, /ratification window is open/);
|
|
382
|
+
assert.doesNotMatch(out, /undefined|null/);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await test('self-development reads as one person, not a handoff to yourself', () => {
|
|
386
|
+
const out = cli.renderDeveloped({ idea: { id: '1', completeness_score: 70 }, author_id: '9', developer_id: '9', self_developed: true });
|
|
387
|
+
assert.match(out, /You had the thought and you developed it/);
|
|
388
|
+
assert.doesNotMatch(out, /credited to builder/);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await test('a sub-bar result says WHICH section is thinnest', () => {
|
|
392
|
+
const out = cli.renderDeveloped({
|
|
393
|
+
idea: { id: '1', completeness_score: 40, rejection: { section_key: 'why_matters', reason_md: 'barely there' } },
|
|
394
|
+
author_id: '9', developer_id: '42', self_developed: false,
|
|
395
|
+
});
|
|
396
|
+
assert.match(out, /thinnest section: why_matters/);
|
|
397
|
+
assert.match(out, /barely there/);
|
|
398
|
+
assert.doesNotMatch(out, /ratification window/, 'no window was opened');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await test('an unscored idea says so rather than printing nothing', () => {
|
|
402
|
+
const out = cli.renderDeveloped({ idea: { id: '1', completeness_score: null }, author_id: '9', developer_id: '42' });
|
|
403
|
+
assert.match(out, /completeness unscored\/100/);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await test('--template is read from the R141 declaration, not restated', () => {
|
|
407
|
+
const out = cli.templateSkeleton();
|
|
408
|
+
for (const f of BASE_TEMPLATE.fields) {
|
|
409
|
+
assert.ok(out.includes(f.key), `names ${f.key}`);
|
|
410
|
+
assert.ok(out.includes(f.label), `names the label for ${f.key}`);
|
|
411
|
+
}
|
|
412
|
+
assert.match(out, /padding scores nothing/, 'it warns about the substance floor');
|
|
413
|
+
// The skeleton must be valid JSON for the five keys, or --fields-file advice lies.
|
|
414
|
+
const json = out.slice(out.indexOf('{'), out.lastIndexOf('}') + 1);
|
|
415
|
+
assert.deepEqual(Object.keys(JSON.parse(json)).sort(), BASE_TEMPLATE.fields.map((f) => f.key).sort());
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await test('the CLI explains each server refusal in the developer\'s own terms', () => {
|
|
419
|
+
const { envelope } = require('../src/bongos/api-errors.js');
|
|
420
|
+
const already = cli.explain(409, envelope('not_a_spark', undefined, { grade: 'full', developed_by: '7' }));
|
|
421
|
+
assert.match(already, /already been developed/);
|
|
422
|
+
assert.match(already, /builder 7/);
|
|
423
|
+
assert.doesNotMatch(already, /undefined/);
|
|
424
|
+
|
|
425
|
+
const notMarked = cli.explain(409, envelope('not_a_spark', undefined, { grade: 'quick' }));
|
|
426
|
+
assert.match(notMarked, /author has to mark it first/);
|
|
427
|
+
|
|
428
|
+
const race = cli.explain(409, envelope('spark_already_developed', undefined, {}));
|
|
429
|
+
assert.match(race, /Their version stands/);
|
|
430
|
+
|
|
431
|
+
const bad = cli.explain(400, envelope('invalid_field_values', undefined, { errors: [{ field: 'fit', reason: 'missing_required' }] }));
|
|
432
|
+
assert.match(bad, /fit: missing required/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
console.log(`\nidea_develop_spark: ${passed} passed, ${failed} failed`);
|
|
436
|
+
if (failed > 0) process.exit(1);
|
|
@@ -28,23 +28,35 @@ async function test(name, fn) {
|
|
|
28
28
|
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// A stub client whose credit_log "table" is an in-memory set of
|
|
32
|
-
// INSERT ... WHERE NOT EXISTS
|
|
33
|
-
//
|
|
34
|
-
|
|
31
|
+
// A stub client whose credit_log "table" is an in-memory set of (builder, reason)
|
|
32
|
+
// pairs, so the INSERT ... WHERE NOT EXISTS behaves exactly like Postgres: a
|
|
33
|
+
// second insert for the same pair is a no-op (rowCount 0).
|
|
34
|
+
//
|
|
35
|
+
// R147 (task 1002923) reshaped the write: the function now first LOOKS UP the
|
|
36
|
+
// idea's parties (captured_by + developed_by) so it — not its caller — decides
|
|
37
|
+
// who earns, and the INSERT carries task_id as a parameter, moving `reason` from
|
|
38
|
+
// $3 to $4. `developedBy` here defaults to null, i.e. an idea nobody developed,
|
|
39
|
+
// which is the case every test below was written for; the split itself is
|
|
40
|
+
// covered in tests/idea_credit_split.mjs.
|
|
41
|
+
function stubClient({ authorId = null, developedBy = null } = {}) {
|
|
35
42
|
const rows = [];
|
|
36
43
|
return {
|
|
37
44
|
rows,
|
|
38
45
|
async query(sql, params) {
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// The R147 parties lookup — who authored this idea, and who developed it.
|
|
47
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
48
|
+
return { rows: authorId == null ? [] : [{ captured_by: authorId, developed_by: developedBy }] };
|
|
49
|
+
}
|
|
50
|
+
// The idempotency subquery must still scope by builder_id AND reason. That
|
|
51
|
+
// was an index concern before (idx_credit_log_builder); under R147 it is
|
|
52
|
+
// also load-bearing for correctness, because a split books two rows under
|
|
53
|
+
// ONE reason and a reason-only predicate would refuse the second payee.
|
|
42
54
|
const builderId = params[0];
|
|
43
|
-
const reason = params[
|
|
44
|
-
assert.match(sql, /builder_id = \$1 AND reason = \$
|
|
55
|
+
const reason = params[3];
|
|
56
|
+
assert.match(sql, /builder_id = \$1 AND reason = \$4/, 'idempotency check must scope by builder_id AND reason');
|
|
45
57
|
const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
|
|
46
58
|
if (exists) return { rowCount: 0, rows: [] };
|
|
47
|
-
rows.push({ builder_id: builderId, delta: params[
|
|
59
|
+
rows.push({ builder_id: builderId, delta: params[2], reason, description: params[4] });
|
|
48
60
|
return { rowCount: 1, rows: [] };
|
|
49
61
|
},
|
|
50
62
|
};
|
|
@@ -54,7 +66,7 @@ const PASS = 72; // clears the 60 bar
|
|
|
54
66
|
const THIN = 40; // below the bar
|
|
55
67
|
|
|
56
68
|
await test('a passing Full Idea books exactly 5 credits to its author', async () => {
|
|
57
|
-
const c = stubClient();
|
|
69
|
+
const c = stubClient({ authorId: 3 });
|
|
58
70
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
59
71
|
assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
60
72
|
assert.equal(r.reason, 'paid');
|
|
@@ -65,7 +77,7 @@ await test('a passing Full Idea books exactly 5 credits to its author', async ()
|
|
|
65
77
|
});
|
|
66
78
|
|
|
67
79
|
await test('re-running pays nothing — idempotent per idea', async () => {
|
|
68
|
-
const c = stubClient();
|
|
80
|
+
const c = stubClient({ authorId: 3 });
|
|
69
81
|
const opts = { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } };
|
|
70
82
|
const first = await credits.awardIdeaFullPassCredit(c, opts);
|
|
71
83
|
const second = await credits.awardIdeaFullPassCredit(c, opts);
|
|
@@ -78,7 +90,7 @@ await test('re-running pays nothing — idempotent per idea', async () => {
|
|
|
78
90
|
});
|
|
79
91
|
|
|
80
92
|
await test('a below-bar or null score books 0 — scoring gates the pay, not capture', async () => {
|
|
81
|
-
const c = stubClient();
|
|
93
|
+
const c = stubClient({ authorId: 3 });
|
|
82
94
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 1, authorId: 3, completenessScore: THIN, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
83
95
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 2, authorId: 3, completenessScore: null, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
84
96
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 3, authorId: 3, completenessScore: undefined, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
@@ -90,13 +102,13 @@ await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardles
|
|
|
90
102
|
const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
|
|
91
103
|
assert.equal(policy.perTaskCredit, false);
|
|
92
104
|
assert.equal(policy.ideatorCredit, true);
|
|
93
|
-
const c = stubClient();
|
|
105
|
+
const c = stubClient({ authorId: 3 });
|
|
94
106
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 5, authorId: 3, completenessScore: PASS, config: policy });
|
|
95
107
|
assert.equal(r.credited, 5, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
|
|
96
108
|
});
|
|
97
109
|
|
|
98
110
|
await test('an instance that opts the ideator lane out pays nothing', async () => {
|
|
99
|
-
const c = stubClient();
|
|
111
|
+
const c = stubClient({ authorId: 3 });
|
|
100
112
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: 3, completenessScore: PASS, config: { ideatorCredit: false } });
|
|
101
113
|
assert.equal(r.credited, 0);
|
|
102
114
|
assert.equal(r.reason, 'ideator_lane_off');
|
|
@@ -104,9 +116,29 @@ await test('an instance that opts the ideator lane out pays nothing', async () =
|
|
|
104
116
|
});
|
|
105
117
|
|
|
106
118
|
await test('a missing author or idea is a safe 0, never a throw', async () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assert.equal((await credits.awardIdeaFullPassCredit(
|
|
119
|
+
// No ideaId at all: refused before the parties lookup even runs.
|
|
120
|
+
const noIdea = stubClient({ authorId: 3 });
|
|
121
|
+
assert.equal((await credits.awardIdeaFullPassCredit(noIdea, { ideaId: null, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
|
|
122
|
+
// An idea the lookup cannot see AND no caller-supplied author → still a safe 0.
|
|
123
|
+
const unknown = stubClient(); // lookup returns no row
|
|
124
|
+
assert.equal((await credits.awardIdeaFullPassCredit(unknown, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
|
|
125
|
+
assert.equal(unknown.rows.length, 0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('R147: the DB is authoritative for the author; the argument is only a fallback', async () => {
|
|
129
|
+
// The caller passing null no longer matters when the row is readable — the
|
|
130
|
+
// whole point of moving the payee decision into this module.
|
|
131
|
+
const known = stubClient({ authorId: 3 });
|
|
132
|
+
const r = await credits.awardIdeaFullPassCredit(known, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
133
|
+
assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
134
|
+
assert.equal(known.rows[0].builder_id, 3, 'paid the author the DB names');
|
|
135
|
+
|
|
136
|
+
// ...and when the lookup finds nothing, the passed author is still honoured,
|
|
137
|
+
// so a caller holding an idea this module cannot see is not silently unpaid.
|
|
138
|
+
const invisible = stubClient();
|
|
139
|
+
const f = await credits.awardIdeaFullPassCredit(invisible, { ideaId: 6, authorId: 7, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
140
|
+
assert.equal(f.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
141
|
+
assert.equal(invisible.rows[0].builder_id, 7);
|
|
110
142
|
});
|
|
111
143
|
|
|
112
144
|
console.log(`\nidea_full_pass_credit: ${passed} passed, ${failed} failed`);
|
|
@@ -101,13 +101,21 @@ await test('the queue asks for grade=spark AND status=open — nothing else', as
|
|
|
101
101
|
assert.match(sql, /i\.status = 'open'/, 'only open ideas');
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
await test('a DEVELOPED spark leaves the queue by GRADE,
|
|
104
|
+
await test('a DEVELOPED spark leaves the queue by GRADE, not by a `developed` filter', async () => {
|
|
105
105
|
await call();
|
|
106
106
|
const sql = listSql().sql;
|
|
107
107
|
// R146 develops in place and flips the grade spark→full, so grade='spark' IS
|
|
108
108
|
// "not yet developed". A second source of truth could disagree with the grade.
|
|
109
|
-
|
|
110
|
-
|
|
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)');
|
|
111
119
|
});
|
|
112
120
|
|
|
113
121
|
await test('newest first, with a stable tiebreak', async () => {
|