@bongos/core 1.19.936 → 1.19.937
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 +52 -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/api/openapi.json +91 -2
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/government/board.js +31 -0
- package/modules/government/db.js +33 -0
- package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/ideas/inbox.js +11 -0
- package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/ratify.js +288 -1
- package/modules/ideas/routes/ratify-goal.js +163 -0
- package/modules/ideas/templates.js +16 -4
- package/modules/lifecycle/goal-from-idea.js +131 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_seed.mjs +1 -0
- package/tests/idea_goal_ratification.mjs +553 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// tests/idea_goal_ratification.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004096 (BV2.R17, spec R23, goal 1000095, criterion
|
|
4
|
+
// `ideator-full-idea-shapes-the-space`) — ratifying a Full Idea into a GOAL:
|
|
5
|
+
// the goal half beside the existing criterion half, one transaction, the five
|
|
6
|
+
// answers landing on the goal.
|
|
7
|
+
//
|
|
8
|
+
// THE REAL GATE CODE RUNS. The `lifecycle` port is registered with the ACTUAL
|
|
9
|
+
// modules/lifecycle/goal-from-idea.js, whose db calls all take the transaction
|
|
10
|
+
// client this suite hands them — so the version gate, the protected-scope gate
|
|
11
|
+
// and the admission gate are exercised rather than stubbed. A suite that
|
|
12
|
+
// registered a permissive fake would prove the ideas half calls A port; the
|
|
13
|
+
// question worth answering is whether it calls one that refuses.
|
|
14
|
+
//
|
|
15
|
+
// THE FIVE THINGS MOST LIKELY TO BE WRONG, each with a test that fails loudly:
|
|
16
|
+
//
|
|
17
|
+
// • THE ANSWERS ARRIVING IN jsonb ORDER. Postgres does not preserve key order,
|
|
18
|
+
// so a composer that walks Object.keys(field_values) renders the five
|
|
19
|
+
// answers in whatever order the row happens to have. §1 stores them
|
|
20
|
+
// SHUFFLED and asserts the declared order comes out.
|
|
21
|
+
// • THE BOARD GATE BEING DECORATIVE. "The ideator recommends, the board
|
|
22
|
+
// decides" is a comment unless a ratification with no passing sitting
|
|
23
|
+
// refuses AND writes nothing. §2.
|
|
24
|
+
// • THE RECOMMENDATION BECOMING A DECISION. The column must be recorded and
|
|
25
|
+
// never branched on: an idea whose author said 'criterion' still ratifies as
|
|
26
|
+
// a goal when the board chose a goal, and NULL survives as NULL. §3.
|
|
27
|
+
// • A METIC-REACHABLE WAY AROUND POST /goals. `idea.ratify`'s floor is metic
|
|
28
|
+
// (ADR 0157), so if this path skips the protected-scope, admission or
|
|
29
|
+
// building-version gates it is a bypass of all three. §4.
|
|
30
|
+
// • PAYING THE AUTHOR TWICE. The board close already awards karma and credit
|
|
31
|
+
// on the pass; a karma INSERT here would double-pay invisibly. §5.
|
|
32
|
+
//
|
|
33
|
+
// DB-free the idea_recommendation.mjs way — pool.query AND pool.connect are
|
|
34
|
+
// patched on the doorway before the modules load, so the transaction itself is
|
|
35
|
+
// driven rather than skipped.
|
|
36
|
+
//
|
|
37
|
+
// Run: node tests/idea_goal_ratification.mjs
|
|
38
|
+
|
|
39
|
+
import { strict as assert } from 'node:assert';
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
|
|
42
|
+
const require = createRequire(import.meta.url);
|
|
43
|
+
const api = require('../src/module-api.js');
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// the stub transaction client — every statement is recorded, and each write
|
|
47
|
+
// echoes back the row a real one would, so a test can assert what was BOUND.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
let queries = [];
|
|
51
|
+
// What the idea + grade read returns. Reassigned per test.
|
|
52
|
+
let storedIdea = null;
|
|
53
|
+
// What the version read returns — the R03 gate's whole input.
|
|
54
|
+
let storedVersion = { id: 'TEST-V1', status: 'planning' };
|
|
55
|
+
// Set to a SQL fragment to make the matching statement throw a unique violation.
|
|
56
|
+
let uniqueViolationOn = null;
|
|
57
|
+
|
|
58
|
+
function answer(sql, params) {
|
|
59
|
+
if (uniqueViolationOn && new RegExp(uniqueViolationOn, 'i').test(sql)) {
|
|
60
|
+
const err = new Error('duplicate key value violates unique constraint');
|
|
61
|
+
err.code = '23505';
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
// The idea + its grade row, locked. Matched before the bare idea_inbox read.
|
|
65
|
+
if (/FROM idea_inbox i/i.test(sql)) return { rows: storedIdea ? [storedIdea] : [] };
|
|
66
|
+
if (/FROM versions WHERE id/i.test(sql)) return { rows: storedVersion ? [storedVersion] : [] };
|
|
67
|
+
if (/INSERT INTO goals/i.test(sql) || /WITH next_order/i.test(sql)) {
|
|
68
|
+
return { rows: [{
|
|
69
|
+
id: '5001', version_id: params[0], title: params[1], description: params[2],
|
|
70
|
+
scope_modules: params[3], status: params[4], created_by: params[5], subtitle: params[6],
|
|
71
|
+
}] };
|
|
72
|
+
}
|
|
73
|
+
if (/INSERT INTO done_when_criteria/i.test(sql)) {
|
|
74
|
+
return { rows: [{
|
|
75
|
+
id: '6001', version_id: params[0], criterion_id: params[1],
|
|
76
|
+
criterion_md: params[2], goal_id: params[3], sort_order: params[4], satisfied: false,
|
|
77
|
+
}] };
|
|
78
|
+
}
|
|
79
|
+
if (/INSERT INTO ideas_goal_ratifications/i.test(sql)) {
|
|
80
|
+
return { rows: [{ id: '7001', ratified_at: 'T' }] };
|
|
81
|
+
}
|
|
82
|
+
if (/COALESCE\(MAX\(sort_order\)/i.test(sql)) return { rows: [{ next: 3 }] };
|
|
83
|
+
if (/SELECT COUNT/i.test(sql)) return { rows: [{ n: 0, count: 0 }] };
|
|
84
|
+
return { rows: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const record = async (sql, params = []) => {
|
|
88
|
+
queries.push({ sql, params });
|
|
89
|
+
return answer(sql, params);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
api.pool.query = record;
|
|
93
|
+
api.pool.connect = async () => ({ query: record, release() {} });
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// the ports — the REAL lifecycle goal-creation policy, and a board stub whose
|
|
97
|
+
// answer each test sets.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
let boardPass = { item_id: '900', outcome: 'passed', opened_at: 'A', closed_at: 'B' };
|
|
101
|
+
const goalFromIdea = require('../modules/lifecycle/goal-from-idea.js');
|
|
102
|
+
const seams = api;
|
|
103
|
+
if (!seams.hasProvider('lifecycle')) {
|
|
104
|
+
seams.registerProvider('lifecycle', { createRatifiedGoal: goalFromIdea.createRatifiedGoal });
|
|
105
|
+
}
|
|
106
|
+
if (!seams.hasProvider('government')) {
|
|
107
|
+
seams.registerProvider('government', { boardPassForIdea: async () => boardPass });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { BASE_V2_TEMPLATE, fieldOrder } = require('../modules/ideas/templates.js');
|
|
111
|
+
const ratify = require('../modules/ideas/ratify.js');
|
|
112
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
113
|
+
// inbox.js is required for its `registerSeams()` side effect (the `ideas.capture`
|
|
114
|
+
// and `ideas.descent` port registrations), the way every route-driving suite here
|
|
115
|
+
// builds the module's surface; the route under test lives in its own file because
|
|
116
|
+
// inbox.js is at the size ratchet (see that file's header).
|
|
117
|
+
require('../modules/ideas/routes/inbox.js')({});
|
|
118
|
+
const routesFactory = require('../modules/ideas/routes/ratify-goal.js');
|
|
119
|
+
|
|
120
|
+
const router = routesFactory({});
|
|
121
|
+
function handlerFor(path, method) {
|
|
122
|
+
const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
|
|
123
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${path} exists`);
|
|
124
|
+
const stack = layer.route.stack.map((s) => s.handle);
|
|
125
|
+
return stack[stack.length - 1];
|
|
126
|
+
}
|
|
127
|
+
const postRatifyGoal = handlerFor('/inbox/:id/ratify-goal', 'post');
|
|
128
|
+
|
|
129
|
+
function drive(handler, { body = {}, params = {}, builderId = '9', rank = 'metic' } = {}) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const req = { body, params, query: {}, builder: { id: builderId, rank } };
|
|
132
|
+
const res = {
|
|
133
|
+
statusCode: 200,
|
|
134
|
+
status(c) { this.statusCode = c; return this; },
|
|
135
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
136
|
+
fail(code, status, extra) {
|
|
137
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
138
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
Promise.resolve(handler(req, res)).catch(reject);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The five answers, each recognisable in the rendered prose.
|
|
146
|
+
const ANSWERS = Object.fromEntries(
|
|
147
|
+
BASE_V2_TEMPLATE.fields.map((f) => [f.key, `ANSWER-${f.key.toUpperCase()}`]),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// A stored row whose field_values keys are in a DIFFERENT order from the
|
|
151
|
+
// declaration — what a jsonb column actually hands back.
|
|
152
|
+
function shuffledAnswers() {
|
|
153
|
+
const keys = [...Object.keys(ANSWERS)].reverse();
|
|
154
|
+
return Object.fromEntries(keys.map((k) => [k, ANSWERS[k]]));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fullIdea(over = {}) {
|
|
158
|
+
return {
|
|
159
|
+
id: '77', title: 'a thought that turned out to be a goal', kind: 'feature', status: 'open',
|
|
160
|
+
captured_by: '42', grade: 'full', template_key: 'base-v2',
|
|
161
|
+
field_values: shuffledAnswers(), recommendation: 'goal', ...over,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const goalInsert = () => queries.find((q) => /INSERT INTO goals/i.test(q.sql) || /WITH next_order/i.test(q.sql));
|
|
166
|
+
const criterionInsert = () => queries.find((q) => /INSERT INTO done_when_criteria/i.test(q.sql));
|
|
167
|
+
const auditInsert = () => queries.find((q) => /INSERT INTO ideas_goal_ratifications/i.test(q.sql));
|
|
168
|
+
const ideaUpdate = () => queries.find((q) => /UPDATE idea_inbox/i.test(q.sql));
|
|
169
|
+
const ran = (re) => queries.some((q) => re.test(q.sql));
|
|
170
|
+
|
|
171
|
+
let passed = 0;
|
|
172
|
+
let failed = 0;
|
|
173
|
+
async function test(name, fn) {
|
|
174
|
+
queries = [];
|
|
175
|
+
storedIdea = fullIdea();
|
|
176
|
+
storedVersion = { id: 'TEST-V1', status: 'planning' };
|
|
177
|
+
boardPass = { item_id: '900', outcome: 'passed', opened_at: 'A', closed_at: 'B' };
|
|
178
|
+
uniqueViolationOn = null;
|
|
179
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
180
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const OK_BODY = { target_version: 'TEST-V1', scope_modules: ['ideas'] };
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// 1. the five answers land on the goal
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
await test('the whole act commits: a goal, its criterion, the idea promoted, an audit row', async () => {
|
|
190
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
191
|
+
assert.equal(r.status, 201, JSON.stringify(r.payload));
|
|
192
|
+
assert.equal(r.payload.goal.id, '5001');
|
|
193
|
+
assert.equal(r.payload.criterion.id, '6001');
|
|
194
|
+
assert.ok(goalInsert(), 'a goal was created');
|
|
195
|
+
assert.ok(criterionInsert(), 'a done-when criterion was created');
|
|
196
|
+
assert.ok(ideaUpdate(), 'the idea was marked promoted');
|
|
197
|
+
assert.ok(auditInsert(), 'the ratification was recorded');
|
|
198
|
+
assert.ok(ran(/^BEGIN$/) && ran(/^COMMIT$/), 'one transaction, committed');
|
|
199
|
+
assert.ok(!ran(/^ROLLBACK$/), 'and not rolled back');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('every one of the five answers reaches the goal description', async () => {
|
|
203
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
204
|
+
const description = goalInsert().params[2];
|
|
205
|
+
for (const f of BASE_V2_TEMPLATE.fields) {
|
|
206
|
+
assert.ok(description.includes(f.label), `the ${f.key} LABEL is rendered`);
|
|
207
|
+
assert.ok(description.includes(ANSWERS[f.key]), `the ${f.key} ANSWER is rendered`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await test('the done-when criterion is the success answer, verbatim', async () => {
|
|
212
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
213
|
+
// The mapping the proof task (BV2.R25) names: done-when from "what does a
|
|
214
|
+
// successful implementation of the idea look like".
|
|
215
|
+
assert.equal(criterionInsert().params[2], ANSWERS.success);
|
|
216
|
+
assert.equal(criterionInsert().params[1], 'idea-77', 'criterion_id is derived from the idea, so a re-ratify trips UNIQUE');
|
|
217
|
+
assert.equal(criterionInsert().params[3], '5001', 'and it hangs on the new goal, not loose on the version');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('the success answer ALSO stays in the description', async () => {
|
|
221
|
+
// Dropping it to avoid "duplication" would take the clearest statement of the
|
|
222
|
+
// goal's purpose out of the one place a builder reads.
|
|
223
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
224
|
+
assert.ok(goalInsert().params[2].includes(ANSWERS.success));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('order comes from the DECLARATION, not from the stored key order', async () => {
|
|
228
|
+
// storedIdea.field_values is reversed — this is the jsonb defect in the flesh.
|
|
229
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
230
|
+
const description = goalInsert().params[2];
|
|
231
|
+
const positions = fieldOrder(BASE_V2_TEMPLATE).map((k) => description.indexOf(ANSWERS[k]));
|
|
232
|
+
const sorted = [...positions].sort((a, b) => a - b);
|
|
233
|
+
assert.deepEqual(positions, sorted, `answers rendered out of declared order: ${positions.join(',')}`);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await test('the goal takes the idea’s own title, and the origin is stated in its prose', async () => {
|
|
237
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
238
|
+
assert.equal(goalInsert().params[1], 'a thought that turned out to be a goal');
|
|
239
|
+
assert.match(goalInsert().params[2], /Full Idea .*77/, 'a reader learns where the scope came from');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// 2. the board gate
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
await test('no passing sitting: refused 409, and NOTHING is written', async () => {
|
|
247
|
+
boardPass = null;
|
|
248
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
249
|
+
assert.equal(r.status, 409);
|
|
250
|
+
assert.equal(r.payload.error, 'BOARD_HAS_NOT_PASSED');
|
|
251
|
+
assert.ok(!goalInsert(), 'no goal');
|
|
252
|
+
assert.ok(!ideaUpdate(), 'the idea is still open');
|
|
253
|
+
assert.ok(!auditInsert(), 'nothing recorded');
|
|
254
|
+
assert.ok(ran(/^ROLLBACK$/), 'the transaction rolled back');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('the passing item id is recorded, so the decision is traceable', async () => {
|
|
258
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
259
|
+
assert.equal(auditInsert().params[3], '900');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await test('an instance with no Board Room cannot ratify at all', async () => {
|
|
263
|
+
// A missing authority system is a denial, never an admission.
|
|
264
|
+
const realResolve = api.resolveOptional;
|
|
265
|
+
api.resolveOptional = (k) => (k === 'government' ? undefined : realResolve(k));
|
|
266
|
+
try {
|
|
267
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
268
|
+
assert.equal(r.status, 503);
|
|
269
|
+
assert.equal(r.payload.error, 'NO_BOARD');
|
|
270
|
+
assert.ok(!ran(/^BEGIN$/), 'refused before the transaction opened');
|
|
271
|
+
} finally { api.resolveOptional = realResolve; }
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// 3. the recommendation is recorded, never obeyed
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
await test('an author who recommended CRITERION is still ratified as a goal', async () => {
|
|
279
|
+
// Spec decision 9: the board may decide the thought was bigger than its author
|
|
280
|
+
// thought. Refusing here would make the recommendation a decision.
|
|
281
|
+
storedIdea = fullIdea({ recommendation: 'criterion' });
|
|
282
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
283
|
+
assert.equal(r.status, 201, 'the board decided, not the author');
|
|
284
|
+
assert.equal(auditInsert().params[6], 'criterion', 'and what they recommended is on the record');
|
|
285
|
+
assert.equal(r.payload.recommendation_at_filing, 'criterion');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await test('an author who said nothing stays having said nothing', async () => {
|
|
289
|
+
storedIdea = fullIdea({ recommendation: null });
|
|
290
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
291
|
+
assert.equal(r.status, 201);
|
|
292
|
+
assert.equal(auditInsert().params[6], null, 'NULL, never a default and never the string "null"');
|
|
293
|
+
assert.equal(r.payload.recommendation_at_filing, null);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// 4. every gate POST /goals applies, applied here
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
await test('a protected scope is refused to a Metic, and creates nothing', async () => {
|
|
301
|
+
const r = await drive(postRatifyGoal, {
|
|
302
|
+
params: { id: '77' }, body: { target_version: 'TEST-V1', scope_modules: ['lifecycle'] }, rank: 'metic',
|
|
303
|
+
});
|
|
304
|
+
assert.equal(r.status, 403);
|
|
305
|
+
assert.equal(r.payload.error, 'ARCHON_REQUIRED_FOR_PROTECTED_SCOPE');
|
|
306
|
+
assert.ok(!goalInsert(), 'no goal — this is the bypass the gate exists to stop');
|
|
307
|
+
assert.ok(ran(/^ROLLBACK$/));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await test('the same protected scope is allowed to an Archon', async () => {
|
|
311
|
+
const r = await drive(postRatifyGoal, {
|
|
312
|
+
params: { id: '77' }, body: { target_version: 'TEST-V1', scope_modules: ['lifecycle'] }, rank: 'archon',
|
|
313
|
+
});
|
|
314
|
+
assert.equal(r.status, 201, JSON.stringify(r.payload));
|
|
315
|
+
assert.deepEqual(goalInsert().params[3], ['lifecycle']);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await test('an unknown module key is a named 400 — a wall that can name nothing is no wall', async () => {
|
|
319
|
+
const r = await drive(postRatifyGoal, {
|
|
320
|
+
params: { id: '77' }, body: { target_version: 'TEST-V1', scope_modules: ['not-a-module'] }, rank: 'archon',
|
|
321
|
+
});
|
|
322
|
+
assert.equal(r.status, 400);
|
|
323
|
+
assert.equal(r.payload.error, 'UNKNOWN_MODULE');
|
|
324
|
+
assert.deepEqual(r.payload.unknown, ['not-a-module']);
|
|
325
|
+
assert.ok(!goalInsert());
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await test('a BUILDING version will not take a ratified goal without an admission', async () => {
|
|
329
|
+
storedVersion = { id: 'TEST-V1', status: 'building' };
|
|
330
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY, rank: 'archon' });
|
|
331
|
+
assert.equal(r.status, 409);
|
|
332
|
+
assert.equal(r.payload.error, 'version_not_planning');
|
|
333
|
+
assert.ok(!goalInsert(), 'a version’s scope does not grow because an idea passed');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await test('an Archon admits it with a reason, and the admission is filed', async () => {
|
|
337
|
+
storedVersion = { id: 'TEST-V1', status: 'building' };
|
|
338
|
+
const r = await drive(postRatifyGoal, {
|
|
339
|
+
params: { id: '77' },
|
|
340
|
+
body: { ...OK_BODY, admit_reason: 'the board carried this and it cannot wait for the next version' },
|
|
341
|
+
rank: 'archon',
|
|
342
|
+
});
|
|
343
|
+
assert.equal(r.status, 201, JSON.stringify(r.payload));
|
|
344
|
+
assert.equal(r.payload.admitted, true);
|
|
345
|
+
// createGoal's statement carries the admission in its last two params.
|
|
346
|
+
const params = goalInsert().params;
|
|
347
|
+
assert.equal(String(params[8]), '9', 'admitted_by is the ratifier');
|
|
348
|
+
assert.match(String(params[9]), /cannot wait/, 'and the reason survives');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await test('a Metic may not admit, and is told by RANK before the version is read', async () => {
|
|
352
|
+
storedVersion = { id: 'TEST-V1', status: 'building' };
|
|
353
|
+
const r = await drive(postRatifyGoal, {
|
|
354
|
+
params: { id: '77' }, body: { ...OK_BODY, admit_reason: 'I would like this now please' }, rank: 'metic',
|
|
355
|
+
});
|
|
356
|
+
assert.equal(r.status, 403);
|
|
357
|
+
assert.equal(r.payload.error, 'ARCHON_REQUIRED_FOR_ADMISSION');
|
|
358
|
+
assert.ok(!ran(/FROM versions WHERE id/i), 'a Metic must not learn whether the override would have worked');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await test('a shipped version is closed history, and no admission reopens it', async () => {
|
|
362
|
+
storedVersion = { id: 'TEST-V1', status: 'shipped' };
|
|
363
|
+
const r = await drive(postRatifyGoal, {
|
|
364
|
+
params: { id: '77' }, body: { ...OK_BODY, admit_reason: 'it really is quite important' }, rank: 'archon',
|
|
365
|
+
});
|
|
366
|
+
assert.equal(r.status, 409);
|
|
367
|
+
assert.equal(r.payload.error, 'version_closed');
|
|
368
|
+
assert.ok(!goalInsert());
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await test('an unknown version is a 404 from the gate, not an FK violation later', async () => {
|
|
372
|
+
storedVersion = null;
|
|
373
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY, rank: 'archon' });
|
|
374
|
+
assert.equal(r.status, 404);
|
|
375
|
+
assert.equal(r.payload.error, 'version_not_found');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await test('an instance with no lifecycle module cannot create a goal', async () => {
|
|
379
|
+
const realResolve = api.resolveOptional;
|
|
380
|
+
api.resolveOptional = (k) => (k === 'lifecycle' ? undefined : realResolve(k));
|
|
381
|
+
try {
|
|
382
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
383
|
+
assert.equal(r.status, 503);
|
|
384
|
+
assert.equal(r.payload.error, 'NO_LIFECYCLE');
|
|
385
|
+
assert.ok(!ran(/^BEGIN$/));
|
|
386
|
+
} finally { api.resolveOptional = realResolve; }
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// 5. who owns it, and who is paid
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
await test('the RATIFIER owns the goal, not whoever had the idea', async () => {
|
|
394
|
+
// createGoal makes created_by the owner AND inserts a 'lead' membership, and a
|
|
395
|
+
// lead authors tasks inside the scope wall. Filing an idea is open to every
|
|
396
|
+
// rank, so owning the goal it became would be an unreviewed grant of authority.
|
|
397
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY, builderId: '9' });
|
|
398
|
+
assert.equal(String(goalInsert().params[5]), '9', 'created_by is the ratifier');
|
|
399
|
+
assert.equal(String(auditInsert().params[7]), '42', 'and the author’s claim is on the audit row');
|
|
400
|
+
assert.equal(String(auditInsert().params[8]), '9');
|
|
401
|
+
assert.notEqual(String(goalInsert().params[5]), '42', 'the author does not become goal lead by having an idea');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await test('nobody is paid here — the board close already paid the author', async () => {
|
|
405
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
406
|
+
assert.ok(!ran(/karma_log/i), 'no karma INSERT: awardAuthorRatifyKarma already ran at the close');
|
|
407
|
+
assert.ok(!ran(/credit/i), 'and no credit line');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// 6. what cannot be ratified
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
await test('a QUICK idea is not a Full Idea and is refused', async () => {
|
|
415
|
+
storedIdea = fullIdea({ grade: 'quick', template_key: null, field_values: null });
|
|
416
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
417
|
+
assert.equal(r.status, 400);
|
|
418
|
+
assert.equal(r.payload.error, 'NOT_A_FULL_IDEA');
|
|
419
|
+
assert.equal(r.payload.grade, 'quick');
|
|
420
|
+
assert.ok(!goalInsert());
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await test('an idea already promoted cannot be ratified again', async () => {
|
|
424
|
+
storedIdea = fullIdea({ status: 'promoted' });
|
|
425
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
426
|
+
assert.equal(r.status, 409);
|
|
427
|
+
assert.equal(r.payload.error, 'IDEA_NOT_OPEN');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await test('a missing idea is a 404', async () => {
|
|
431
|
+
storedIdea = null;
|
|
432
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
433
|
+
assert.equal(r.status, 404);
|
|
434
|
+
assert.equal(r.payload.error, 'IDEA_NOT_FOUND');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await test('a base-v1 Full Idea is refused, naming its vintage', async () => {
|
|
438
|
+
// v1's five questions contain no answer describing a finished implementation,
|
|
439
|
+
// so there is nothing to write a done-when FROM — and inventing one is the
|
|
440
|
+
// never-invent-a-field rule. Its author can resubmit against v2.
|
|
441
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { purpose: 'x', big_picture: 'y' } });
|
|
442
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
443
|
+
assert.equal(r.status, 400);
|
|
444
|
+
assert.equal(r.payload.error, 'CANNOT_COMPOSE_GOAL');
|
|
445
|
+
assert.equal(r.payload.reason, 'template_has_no_goal_shape');
|
|
446
|
+
assert.equal(r.payload.template_key, 'base-v1');
|
|
447
|
+
assert.ok(!goalInsert());
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await test('a blank success answer is refused rather than made an empty criterion', async () => {
|
|
451
|
+
// A goal whose only criterion is '' is one the close gate can never satisfy.
|
|
452
|
+
storedIdea = fullIdea({ field_values: { ...ANSWERS, success: ' ' } });
|
|
453
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
454
|
+
assert.equal(r.status, 400);
|
|
455
|
+
assert.equal(r.payload.reason, 'done_when_answer_missing');
|
|
456
|
+
assert.equal(r.payload.field, 'success');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await test('a non-numeric ratifier is a named 400, not a generic 500', async () => {
|
|
460
|
+
// Unreachable under normal auth — req.builder.id is always numeric — so this
|
|
461
|
+
// is the defensive branch being actually exercised rather than assumed. Left
|
|
462
|
+
// unmapped it fell through to `ratify_goal_failed`, which says nothing.
|
|
463
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY, builderId: 'not-a-number' });
|
|
464
|
+
assert.equal(r.status, 400);
|
|
465
|
+
assert.equal(r.payload.error, 'BAD_RATIFIER');
|
|
466
|
+
assert.ok(!ran(/^BEGIN$/), 'refused before the transaction opened');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await test('a refusal puts named fields on the wire, never the whole thrown object', async () => {
|
|
470
|
+
// The refusals spread two INTERNAL objects into themselves — the composer's
|
|
471
|
+
// result and the lifecycle port's verdict — so a `{ ...err }` response body
|
|
472
|
+
// would publish `ok`, and one edit later `description` and `criterionMd`.
|
|
473
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { purpose: 'x' } });
|
|
474
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
475
|
+
assert.equal(r.status, 400);
|
|
476
|
+
assert.equal(r.payload.reason, 'template_has_no_goal_shape', 'the useful fields still arrive');
|
|
477
|
+
assert.equal(r.payload.ok, undefined, 'the composer’s own ok flag does not ride out');
|
|
478
|
+
assert.equal(r.payload.description, undefined);
|
|
479
|
+
assert.equal(r.payload.criterionMd, undefined);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await test('a gate refusal keeps its status and its sentence, and nothing else', async () => {
|
|
483
|
+
const r = await drive(postRatifyGoal, {
|
|
484
|
+
params: { id: '77' }, body: { target_version: 'TEST-V1', scope_modules: ['lifecycle'] }, rank: 'metic',
|
|
485
|
+
});
|
|
486
|
+
assert.equal(r.status, 403, 'the port’s own status is rendered, not re-mapped');
|
|
487
|
+
assert.deepEqual(r.payload.scope_modules, ['lifecycle'], 'the caller learns which scope refused');
|
|
488
|
+
assert.equal(r.payload.ok, undefined);
|
|
489
|
+
assert.equal(r.payload.code, undefined, 'the code rides in the envelope, not twice in the body');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await test('target_version is required — a goal cannot be created nowhere', async () => {
|
|
493
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: { scope_modules: [] } });
|
|
494
|
+
assert.equal(r.status, 400);
|
|
495
|
+
assert.ok(!ran(/^BEGIN$/), 'the body validator refuses before anything opens');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
await test('a double ratification trips UNIQUE and reads as already ratified', async () => {
|
|
499
|
+
uniqueViolationOn = 'INSERT INTO ideas_goal_ratifications';
|
|
500
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
501
|
+
assert.equal(r.status, 409);
|
|
502
|
+
assert.equal(r.payload.error, 'IDEA_ALREADY_RATIFIED');
|
|
503
|
+
assert.ok(ran(/^ROLLBACK$/), 'so the goal the losing transaction made is rolled back');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// 7. the composer, exercised directly
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
await test('the goal shape registry maps only vintages it actually declares', async () => {
|
|
511
|
+
assert.ok(Object.hasOwn(ratify.GOAL_SHAPE_BY_TEMPLATE, 'base-v2'));
|
|
512
|
+
assert.equal(Object.hasOwn(ratify.GOAL_SHAPE_BY_TEMPLATE, 'base-v1'), false,
|
|
513
|
+
'v1 has no success answer; mapping it would invent one');
|
|
514
|
+
// Own-property lookup only — a template_key of 'constructor' must not resolve
|
|
515
|
+
// up the prototype chain to a truthy non-shape.
|
|
516
|
+
const r = ratify.composeGoalFromFullIdea({ ideaId: 1, templateKey: 'constructor', fieldValues: ANSWERS });
|
|
517
|
+
assert.equal(r.ok, false);
|
|
518
|
+
assert.equal(r.reason, 'unknown_template');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
await test('the composer is pure and refuses a missing field_values', async () => {
|
|
522
|
+
for (const bad of [null, undefined, 'a string', [1, 2]]) {
|
|
523
|
+
const r = ratify.composeGoalFromFullIdea({ ideaId: 1, templateKey: 'base-v2', fieldValues: bad });
|
|
524
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} is refused`);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
await test('a field the row does not carry is skipped, not rendered as an empty heading', async () => {
|
|
529
|
+
const { why_had, ...withoutWhyHad } = ANSWERS;
|
|
530
|
+
const r = ratify.composeGoalFromFullIdea({ ideaId: 1, templateKey: 'base-v2', fieldValues: withoutWhyHad });
|
|
531
|
+
assert.equal(r.ok, true);
|
|
532
|
+
const label = BASE_V2_TEMPLATE.fields.find((f) => f.key === 'why_had').label;
|
|
533
|
+
assert.ok(!r.description.includes(label), 'no heading for a question with no answer under it');
|
|
534
|
+
assert.ok(r.description.includes(ANSWERS.scope), 'the answers that exist are still rendered');
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// 8. the facade
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
await test('the facade re-exports the transaction and nothing nobody needs', async () => {
|
|
542
|
+
assert.equal(inbox.ratifyFullIdeaAsGoal, ratify.ratifyFullIdeaAsGoal);
|
|
543
|
+
// The pure composer and the shape registry are NOT on the facade — a
|
|
544
|
+
// re-export no caller needs is an unused export the dead-code ratchet counts.
|
|
545
|
+
// Anything wanting them requires ./ratify directly, as this file does.
|
|
546
|
+
assert.equal(inbox.composeGoalFromFullIdea, undefined);
|
|
547
|
+
assert.equal(inbox.GOAL_SHAPE_BY_TEMPLATE, undefined);
|
|
548
|
+
// And the criterion half is untouched beside it.
|
|
549
|
+
assert.equal(typeof inbox.ratifyCriterionProposal, 'function');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
console.log(`\nidea_goal_ratification: ${passed} passed, ${failed} failed`);
|
|
553
|
+
if (failed > 0) process.exit(1);
|