@bongos/core 1.19.646 → 1.19.648
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 +48 -33
- 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 +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +142 -3
- package/docs/api-reference.md +5 -3
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_237_box_widen_paths.sql +40 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/dev-box/box-access.js +128 -4
- package/modules/dev-box/boxes.js +21 -0
- package/modules/dev-box/routes/box.js +108 -2
- package/modules/lifecycle/db-tasks.js +13 -1
- package/modules/lifecycle/db.js +40 -64
- package/modules/lifecycle/routes/task-write-routes.js +28 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-sync.js +84 -1
- package/scripts/gds/ship-flow.js +69 -10
- package/src/module-api.js +1 -1
- package/tests/box_access.mjs +155 -0
- package/tests/box_sync_scope_report.mjs +45 -0
- package/tests/home_bounded_reads.mjs +14 -5
- package/tests/lifecycle_facade_surface.mjs +112 -0
- package/tests/migration_allocation.mjs +28 -0
- package/tests/patch_gate_ordering.mjs +4 -0
- package/tests/patch_value_summary_e2e.mjs +142 -0
- package/tests/ship_cannot_lie.mjs +42 -13
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// tests/patch_value_summary_e2e.mjs — PATCH /tasks/:id value_summary over real
|
|
2
|
+
// HTTP (task 1003817).
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND FILE. migration_allocation.mjs pins the two halves in isolation:
|
|
5
|
+
// the route's schema/gate/write as SOURCE TEXT, and db.updateTaskValueSummary
|
|
6
|
+
// driven against a stubbed pool so the real `SET value_summary =` SQL is proven.
|
|
7
|
+
// Neither exercises the WIRING, and the wiring is where this class of change
|
|
8
|
+
// fails — the whole defect being fixed is a value that reached three consumers
|
|
9
|
+
// and never reached the row. Specifically, source text cannot see that the
|
|
10
|
+
// route's STRICT validator (ADR 0118) actually admits the new field: before this
|
|
11
|
+
// task, `value_summary` on this route was a hard 400 `unknown_field`, and a
|
|
12
|
+
// schema entry that is subtly wrong (mis-typed, mis-named, declared on the
|
|
13
|
+
// create schema instead) fails exactly the same way while every text assertion
|
|
14
|
+
// still passes.
|
|
15
|
+
//
|
|
16
|
+
// So this drives the REAL router over a real socket with the db layer stubbed at
|
|
17
|
+
// the module boundary, the way task_visual_route_gate.mjs and
|
|
18
|
+
// goal_archive_route_e2e.mjs do — the router under test is the shipped one.
|
|
19
|
+
//
|
|
20
|
+
// THE CASES WORTH THE FILE: (2) proves acceptance 1 end to end — a corrected
|
|
21
|
+
// summary reaches the write. (4) and (5) prove acceptance 2 from the other side:
|
|
22
|
+
// an OMITTED field must not touch the row at all, and a whitespace-only one must
|
|
23
|
+
// be refused rather than trimmed into a blank record. `value_summary` is the
|
|
24
|
+
// public shipped-value sentence, so blanking it is the one failure mode that is
|
|
25
|
+
// worse than the bug.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/patch_value_summary_e2e.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
process.env.NODE_ENV = 'test';
|
|
33
|
+
|
|
34
|
+
const express = require('express');
|
|
35
|
+
const api = require('../src/module-api.js');
|
|
36
|
+
const db = require('../modules/lifecycle/db.js');
|
|
37
|
+
|
|
38
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
|
|
39
|
+
api.requirePermission = () => (req, _res, next) => next();
|
|
40
|
+
|
|
41
|
+
const TASK = { id: 5150, title: 'a task', status: 'completed', version_id: 'BONGOS-V2', value_summary: 'the FIRST ship sentence' };
|
|
42
|
+
|
|
43
|
+
// Every write the handler can reach, recorded rather than performed — so a test
|
|
44
|
+
// can assert not only what WAS written but that nothing else was.
|
|
45
|
+
let writes = [];
|
|
46
|
+
db.getTask = async () => ({ ...TASK });
|
|
47
|
+
db.updateTaskValueSummary = async (id, v) => { writes.push({ fn: 'value_summary', id, v }); return { ...TASK, value_summary: v }; };
|
|
48
|
+
db.updateTaskTitle = async (id, v) => { writes.push({ fn: 'title', id, v }); return { ...TASK, title: v }; };
|
|
49
|
+
db.updateTaskKind = async (id, v) => { writes.push({ fn: 'kind', id, v }); return { ...TASK, kind: v }; };
|
|
50
|
+
|
|
51
|
+
const app = express();
|
|
52
|
+
app.use(express.json());
|
|
53
|
+
app.use((req, res, next) => {
|
|
54
|
+
res.fail = (code, statusOrOpts, details) => {
|
|
55
|
+
const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
|
|
56
|
+
return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
|
|
57
|
+
};
|
|
58
|
+
next();
|
|
59
|
+
});
|
|
60
|
+
app.use('/api/gds', require('../modules/lifecycle/routes/tasks.js')());
|
|
61
|
+
const server = app.listen(0);
|
|
62
|
+
await new Promise((r) => server.once('listening', r));
|
|
63
|
+
const base = `http://127.0.0.1:${server.address().port}/api/gds`;
|
|
64
|
+
|
|
65
|
+
const patch = async (body) => {
|
|
66
|
+
writes = [];
|
|
67
|
+
const r = await fetch(`${base}/tasks/${TASK.id}`, {
|
|
68
|
+
method: 'PATCH',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
return { status: r.status, body: await r.json().catch(() => null), writes };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let passed = 0;
|
|
76
|
+
let failed = 0;
|
|
77
|
+
async function t(name, fn) {
|
|
78
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
79
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('PATCH /tasks/:id value_summary (task 1003817):');
|
|
83
|
+
|
|
84
|
+
// 1. The regression guard: this is what the route used to do with the field.
|
|
85
|
+
await t('the field is no longer rejected as an unknown field', async () => {
|
|
86
|
+
const r = await patch({ value_summary: 'anything' });
|
|
87
|
+
assert.notEqual(r.body && r.body.error, 'unknown_field',
|
|
88
|
+
'the strict validator must ADMIT value_summary — a missing schema entry looks exactly like the original bug');
|
|
89
|
+
assert.notEqual(r.body && r.body.error, 'no_supported_fields',
|
|
90
|
+
'and the supported-fields guard must count it, or a value_summary-only PATCH is refused');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 2. ACCEPTANCE 1, end to end.
|
|
94
|
+
await t('a corrected summary reaches the write, trimmed', async () => {
|
|
95
|
+
const r = await patch({ value_summary: ' the CORRECTED sentence ' });
|
|
96
|
+
assert.equal(r.status, 200, `expected 200, got ${r.status} ${JSON.stringify(r.body)}`);
|
|
97
|
+
assert.deepEqual(r.writes, [{ fn: 'value_summary', id: TASK.id, v: 'the CORRECTED sentence' }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 3. It composes with the other planning fields rather than replacing them.
|
|
101
|
+
await t('it can travel with another field in one body', async () => {
|
|
102
|
+
const r = await patch({ value_summary: 'both', title: 'new title' });
|
|
103
|
+
assert.equal(r.status, 200);
|
|
104
|
+
const fns = r.writes.map((w) => w.fn).sort();
|
|
105
|
+
assert.deepEqual(fns, ['title', 'value_summary']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 4. ACCEPTANCE 2, from the route's side.
|
|
109
|
+
await t('ACCEPTANCE 2: an omitted value_summary writes nothing at all', async () => {
|
|
110
|
+
const r = await patch({ title: 'only the title' });
|
|
111
|
+
assert.equal(r.status, 200);
|
|
112
|
+
assert.deepEqual(r.writes.filter((w) => w.fn === 'value_summary'), [],
|
|
113
|
+
'an omitted field must not reach the column — not even to write back what was there');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 5. The failure mode worse than the bug.
|
|
117
|
+
await t('a blank or whitespace-only summary is REFUSED, never written', async () => {
|
|
118
|
+
for (const bad of ['', ' ', '\t\n ']) {
|
|
119
|
+
const r = await patch({ value_summary: bad });
|
|
120
|
+
assert.equal(r.status, 400, `${JSON.stringify(bad)} must be refused, got ${r.status}`);
|
|
121
|
+
assert.deepEqual(r.writes, [], `${JSON.stringify(bad)} must not reach the column`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// 6. And the refusal has to be diagnosable, since the caller is usually a script.
|
|
126
|
+
await t('the refusal names the field and says omission is how you leave it alone', async () => {
|
|
127
|
+
const r = await patch({ value_summary: ' ' });
|
|
128
|
+
assert.equal(r.body.error, 'bad_value_summary');
|
|
129
|
+
assert.match(r.body.message, /never blanked/i);
|
|
130
|
+
assert.match(r.body.message, /omit/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 7. The cap is the shared one, not a number re-typed on this route.
|
|
134
|
+
await t('an over-long summary is refused by the shared LIMITS cap', async () => {
|
|
135
|
+
const r = await patch({ value_summary: 'x'.repeat(api.LIMITS.VALUE_SUMMARY + 1) });
|
|
136
|
+
assert.equal(r.status, 400);
|
|
137
|
+
assert.deepEqual(r.writes, []);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
server.close();
|
|
141
|
+
console.log(`\npatch_value_summary_e2e: ${passed} passed, ${failed} failed`);
|
|
142
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -242,15 +242,16 @@ test('the refusal is wired into the regrade path, before any grade spend', () =>
|
|
|
242
242
|
.replace(/\/\/[^\n]*/g, '')), 'usage does not offer --notes');
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
test('ACCEPTANCE 3: --summary reaches the
|
|
245
|
+
test('ACCEPTANCE 3: --summary reaches the grader AND the task row on a re-grade', () => {
|
|
246
246
|
// Task 1003702's third acceptance asked whether --summary reaches the stored
|
|
247
|
-
// value_summary on a re-grade. It
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
247
|
+
// value_summary on a re-grade. It did NOT — a separate defect, filed and fixed
|
|
248
|
+
// as task 1003817, which added value_summary to the PATCH /tasks/:id schema and
|
|
249
|
+
// the persistRegradeSummary call below. This test used to pin the ABSENCE of a
|
|
250
|
+
// writer; it now pins its PRESENCE, for the reason this whole file exists: the
|
|
251
|
+
// panel reads the DIFF, so an answer that lives only in ship notes is an answer
|
|
252
|
+
// nobody can check — which is the very bug task 1003702 was.
|
|
252
253
|
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
253
|
-
const regrade = src.slice(src.indexOf('async function
|
|
254
|
+
const regrade = src.slice(src.indexOf('async function persistRegradeSummary'));
|
|
254
255
|
const code = regrade.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not write
|
|
255
256
|
|
|
256
257
|
// Half one — the threading is intact, so nothing regressed: --summary still
|
|
@@ -261,10 +262,38 @@ test('ACCEPTANCE 3: --summary reaches the GRADER on a re-grade but never the tas
|
|
|
261
262
|
'the summary still reaches the grader prompt');
|
|
262
263
|
assert.match(code, /valueSummary: summary/, 'and the completion card');
|
|
263
264
|
|
|
264
|
-
// Half two — and there
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
assert.
|
|
269
|
-
'regradeMain
|
|
265
|
+
// Half two — and now there IS a writer, wired into the path (the (1) lesson:
|
|
266
|
+
// a helper nothing calls is not a fix).
|
|
267
|
+
assert.match(code, /patchTasksId\(\{[\s\S]{0,80}?body: \{ value_summary: next \}/,
|
|
268
|
+
'persistRegradeSummary PATCHes value_summary onto the task row');
|
|
269
|
+
assert.match(code, /await persistRegradeSummary\(\{ taskId, summaryArg, task \}\)/,
|
|
270
|
+
'and regradeMain actually calls it');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('a re-grade that omits --summary leaves the stored value_summary alone', () => {
|
|
274
|
+
// ACCEPTANCE 2. The row must survive an omitted flag: `summary` falls back to
|
|
275
|
+
// task.value_summary, so writing that fallback back would be a no-op at best
|
|
276
|
+
// and, on any future refactor of the fallback, a way to overwrite the record
|
|
277
|
+
// with something derived. persistRegradeSummary keys on summaryArg — the RAW
|
|
278
|
+
// flag — not on the resolved `summary`, and returns before the PATCH when it
|
|
279
|
+
// is absent. Pinned as source structure because the alternative (a live
|
|
280
|
+
// re-grade) costs a 4-worker panel run.
|
|
281
|
+
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
282
|
+
const start = src.indexOf('async function persistRegradeSummary');
|
|
283
|
+
const fn = src.slice(start, src.indexOf('async function regradeMain', start));
|
|
284
|
+
const code = fn.replace(/\/\/[^\n]*/g, '');
|
|
285
|
+
|
|
286
|
+
// It reads the raw flag, never the resolved fallback.
|
|
287
|
+
assert.match(code, /const next = \(summaryArg \|\| ''\)\.trim\(\)/,
|
|
288
|
+
'the guard keys on the raw --summary, not the stored-value fallback');
|
|
289
|
+
|
|
290
|
+
// Both early returns precede the only PATCH in the function.
|
|
291
|
+
const omitted = code.indexOf("reason: 'omitted'");
|
|
292
|
+
const unchanged = code.indexOf("reason: 'unchanged'");
|
|
293
|
+
const patch = code.indexOf('patchTasksId');
|
|
294
|
+
assert.ok(omitted !== -1 && omitted < patch, 'an omitted --summary returns before the write');
|
|
295
|
+
assert.ok(unchanged !== -1 && unchanged < patch, 'an unchanged --summary returns before the write too');
|
|
296
|
+
|
|
297
|
+
// And a failed write never aborts the re-grade — no process.exit, no throw.
|
|
298
|
+
assert.ok(!/process\.exit/.test(code), 'a summary that will not persist must not kill the re-grade');
|
|
270
299
|
});
|