@bongos/core 1.19.588 → 1.19.590
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 +31 -21
- package/clients/bongos-client/index.d.ts +3 -2
- package/docs/adr/0264-the-ten-working-areas.md +144 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +30 -4
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +126 -1
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/goal-route-authz.js +134 -0
- package/modules/lifecycle/routes/goals.js +106 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_archive_disposition.mjs +207 -6
- package/tests/goal_archive_route_e2e.mjs +135 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/goal_archive_route_e2e.mjs — the archive two-step over real HTTP
|
|
2
|
+
// (BV1.R14, task 1003601, goal 1000086, ADR 0250 D3).
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND FILE. goal_archive_disposition.mjs pins the two halves in
|
|
5
|
+
// isolation — the pure planner's truth table, and the writer's transaction shape
|
|
6
|
+
// against a fake pool. Neither exercises the WIRING: that the route reads the
|
|
7
|
+
// body it declares, calls the planner with the right arguments, renders its
|
|
8
|
+
// verdict verbatim, resolves move targets BEFORE writing, and hands the writer a
|
|
9
|
+
// plan. Every one of those is a place a correct planner and a correct writer can
|
|
10
|
+
// still add up to a broken endpoint, and each was a real risk on this change
|
|
11
|
+
// (the route previously read no body at all).
|
|
12
|
+
//
|
|
13
|
+
// So this drives the REAL router over a real socket, with the db layer stubbed at
|
|
14
|
+
// the module boundary the way task_visual_route_gate.mjs does — the router under
|
|
15
|
+
// test is the shipped one, not a re-assembly of it.
|
|
16
|
+
//
|
|
17
|
+
// THE CASE WORTH THE FILE ON ITS OWN is #6: a typo'd `disposition` (singular).
|
|
18
|
+
// Without the ADR 0118 schema the handler reads `req.body.dispositions` as
|
|
19
|
+
// undefined and takes the REFUSAL branch — so a caller who wrote a complete map
|
|
20
|
+
// under a misspelled key is told the goal holds open work, which is true,
|
|
21
|
+
// unhelpful, and indistinguishable from having sent nothing. That is the failure
|
|
22
|
+
// shape that teaches an agent to retry with a different map instead of fixing the
|
|
23
|
+
// key.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/goal_archive_route_e2e.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
process.env.NODE_ENV = 'test';
|
|
31
|
+
|
|
32
|
+
const express = require('express');
|
|
33
|
+
const api = require('../src/module-api.js');
|
|
34
|
+
const db = require('../modules/lifecycle/db.js');
|
|
35
|
+
|
|
36
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
|
|
37
|
+
api.requirePermission = () => (req, _res, next) => next();
|
|
38
|
+
|
|
39
|
+
let GOALS = {
|
|
40
|
+
7: { id: 7, status: 'open', title: 'cut me', version_id: 'BONGOS-V1' },
|
|
41
|
+
42: { id: 42, status: 'open', title: 'survivor', version_id: 'BONGOS-V1' },
|
|
42
|
+
99: { id: 99, status: 'archived', title: 'closed', version_id: 'BONGOS-V1' },
|
|
43
|
+
};
|
|
44
|
+
let OPEN = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
|
|
45
|
+
let applied = null;
|
|
46
|
+
db.getGoal = async (id) => GOALS[Number(id)] ?? null;
|
|
47
|
+
db.isGoalMember = async () => true;
|
|
48
|
+
db.openTasksInGoal = async () => ({ total: OPEN.length, tasks: OPEN });
|
|
49
|
+
db.setGoalStatus = async ({ goalId }) => ({ ...GOALS[goalId], status: 'archived' });
|
|
50
|
+
db.archiveGoalWithDispositions = async ({ goalId, plan, reason }) => {
|
|
51
|
+
applied = { goalId, plan, reason };
|
|
52
|
+
return { goal: { ...GOALS[goalId], status: 'archived' }, applied: plan.map(p => ({ task_id: String(p.taskId), verb: p.verb, result: p.verb === 'abandon' ? 'abandoned' : 'moved' })) };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const app = express();
|
|
56
|
+
app.use(express.json());
|
|
57
|
+
app.use((req, res, next) => {
|
|
58
|
+
res.fail = (code, statusOrOpts, details) => {
|
|
59
|
+
const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
|
|
60
|
+
return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
|
|
61
|
+
};
|
|
62
|
+
next();
|
|
63
|
+
});
|
|
64
|
+
app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
|
|
65
|
+
const server = app.listen(0);
|
|
66
|
+
await new Promise(r => server.once('listening', r));
|
|
67
|
+
const base = `http://127.0.0.1:${server.address().port}/api/gds`;
|
|
68
|
+
const post = async (p, body) => {
|
|
69
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
70
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let n = 0; const ok = (m) => { n++; console.log(' ✓', m); };
|
|
74
|
+
|
|
75
|
+
// 1. bare archive of a goal holding work → the R10 refusal, WITH the tasks
|
|
76
|
+
let r = await post('/goals/7/archive');
|
|
77
|
+
assert.equal(r.status, 409); assert.equal(r.body.error, 'goal_holds_open_tasks');
|
|
78
|
+
assert.equal(r.body.details.tasks.length, 2);
|
|
79
|
+
ok('bare archive refuses 409 goal_holds_open_tasks and returns both tasks');
|
|
80
|
+
|
|
81
|
+
// 2. a partial map → refused, names the undecided task
|
|
82
|
+
r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'abandon' } } });
|
|
83
|
+
assert.equal(r.status, 400); assert.equal(r.body.error, 'disposition_incomplete');
|
|
84
|
+
assert.deepEqual(r.body.details.tasks.map(t => t.id), ['2']);
|
|
85
|
+
ok('partial map refuses 400 disposition_incomplete, naming task 2');
|
|
86
|
+
|
|
87
|
+
// 3. complete map with no reason → refused
|
|
88
|
+
r = await post('/goals/7/archive', { dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } } });
|
|
89
|
+
assert.equal(r.status, 400); assert.equal(r.body.error, 'archive_reason_required');
|
|
90
|
+
ok('an abandon with no reason refuses 400 archive_reason_required');
|
|
91
|
+
|
|
92
|
+
// 4. move into a CLOSED goal → refused before any write
|
|
93
|
+
applied = null;
|
|
94
|
+
r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 99 }, 2: { verb: 'abandon' } } });
|
|
95
|
+
assert.equal(r.status, 409); assert.equal(r.body.error, 'bad_disposition');
|
|
96
|
+
assert.equal(applied, null, 'nothing was written');
|
|
97
|
+
ok('move into an archived goal refuses 409 bad_disposition, writing nothing');
|
|
98
|
+
|
|
99
|
+
// 5. move into the goal being archived → refused
|
|
100
|
+
r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
|
|
101
|
+
assert.equal(r.status, 400); assert.equal(r.body.error, 'bad_disposition');
|
|
102
|
+
ok('move into the goal being archived refuses 400 bad_disposition');
|
|
103
|
+
|
|
104
|
+
// 6. unknown body field → rejected by the ADR 0118 schema
|
|
105
|
+
r = await post('/goals/7/archive', { reason: 'cut', disposition: { 1: { verb: 'abandon' } } });
|
|
106
|
+
assert.equal(r.status, 400);
|
|
107
|
+
assert.equal(r.body.error?.code ?? r.body.error, 'validation_failed');
|
|
108
|
+
ok('a typo\'d `disposition` (singular) is rejected as validation_failed, not read as "none given"');
|
|
109
|
+
|
|
110
|
+
// 7. THE HAPPY PATH — complete map, one abandon + one move
|
|
111
|
+
applied = null;
|
|
112
|
+
r = await post('/goals/7/archive', { reason: 'cut in the ten-areas review', dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
|
|
113
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
114
|
+
assert.equal(r.body.goal.status, 'archived');
|
|
115
|
+
assert.deepEqual(r.body.applied, [
|
|
116
|
+
{ task_id: '1', verb: 'abandon', result: 'abandoned' },
|
|
117
|
+
{ task_id: '2', verb: 'move', result: 'moved' },
|
|
118
|
+
]);
|
|
119
|
+
assert.deepEqual(applied.plan, [
|
|
120
|
+
{ taskId: 1, verb: 'abandon', toGoalId: null },
|
|
121
|
+
{ taskId: 2, verb: 'move', toGoalId: 42 },
|
|
122
|
+
]);
|
|
123
|
+
assert.equal(applied.reason, 'cut in the ten-areas review');
|
|
124
|
+
ok('a complete map archives the goal and reports what happened to each task');
|
|
125
|
+
|
|
126
|
+
// 8. a goal holding nothing is still ONE call, no reason needed
|
|
127
|
+
OPEN = [];
|
|
128
|
+
r = await post('/goals/7/archive');
|
|
129
|
+
assert.equal(r.status, 200); assert.equal(r.body.goal.status, 'archived');
|
|
130
|
+
assert.deepEqual(r.body.applied, []);
|
|
131
|
+
ok('a goal holding nothing archives in one call with no reason');
|
|
132
|
+
|
|
133
|
+
assert.equal(n, 8, 'every check must run — an early return would pass silently');
|
|
134
|
+
console.log(`\n${n}/8 end-to-end checks passed against the real router.`);
|
|
135
|
+
server.close();
|