@bongos/core 1.19.535 → 1.19.536

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.
@@ -0,0 +1,337 @@
1
+ // tests/project_settings_page.mjs — the five fundamentals, and the rule that keeps
2
+ // them honest (task 1003280, goal 1000072, criterion legibility-project-settings).
3
+ //
4
+ // Three things are pinned here, and the third matters most.
5
+ //
6
+ // 1. The route works: one read returns all five rows, the timer round-trips through
7
+ // the shared parser, and the category set can be renamed, reordered and retired.
8
+ //
9
+ // 2. AN ABSENT FUNDAMENTAL IS REPORTED ABSENT. `upmarket_percent` has no knob behind
10
+ // it anywhere in this codebase, and equity is a settled owner decision that it is
11
+ // NOT real. Both must render as themselves. A plausible zero on either would be a
12
+ // fabricated claim about the project's economics on the page its owner trusts most
13
+ // — a worse failure than the illegibility this goal exists to fix.
14
+ //
15
+ // 3. THE PAGE RE-TYPES NOTHING. No formula, bound, default or file path is duplicated
16
+ // in the client. That is the whole reason this page can be believed: a settings
17
+ // surface carrying its own copy of `round(cost × 1.20)` or of "1..365" drifts from
18
+ // the code that actually decides, which is precisely ADR 0226's failure one release
19
+ // later. This is asserted against the real file, because a rule that lives only in
20
+ // a comment comes back.
21
+ //
22
+ // Run: node --test tests/project_settings_page.mjs
23
+ import { strict as assert } from 'node:assert';
24
+ import { test } from 'node:test';
25
+ import { createRequire } from 'node:module';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
33
+
34
+ // The anti-drift assertions below are about what the code DOES, so they read it with
35
+ // full-line comments removed. Without this they trip on the very prose that explains
36
+ // the rule — this file's own header quotes "1..365" as the thing not to re-type, and
37
+ // the grant migration names the pre-rename table to warn against copying it.
38
+ const stripLineComments = (src, marker) => src
39
+ .split('\n')
40
+ .filter((line) => !line.trimStart().startsWith(marker))
41
+ .join('\n');
42
+
43
+ const gc = require(path.join(ROOT, 'modules', 'lifecycle', 'goal-category.js'));
44
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
45
+
46
+ const fakeRes = () => ({
47
+ code: null, statusCode: 200, payload: null, headers: {},
48
+ fail(code, status, extra) { this.code = code; this.statusCode = typeof status === 'number' ? status : status?.status ?? 400; this.payload = { error: code, ...(extra || {}) }; return this; },
49
+ status(c) { this.statusCode = c; return this; },
50
+ set(k, v) { this.headers[k] = v; return this; },
51
+ json(p) { this.payload = p; return this; },
52
+ });
53
+
54
+ // ---- the route, executed for real -----------------------------------------
55
+ //
56
+ // The router is built against the REAL module-api and the REAL goal-category; only
57
+ // the two calls that would reach Postgres are swapped. Stubbing the row builders
58
+ // instead would prove nothing about the shape the page actually receives.
59
+ const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, usdPeg: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
60
+ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
61
+ const saved = {
62
+ resolveRotDays: api.resolveRotDays,
63
+ resolveOptional: api.resolveOptional,
64
+ list: gc.listCategories,
65
+ set: api.projectSettings.set,
66
+ };
67
+ api.resolveRotDays = async () => timer;
68
+ // reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
69
+ const rewardPort = reward === 'throws'
70
+ ? { rewardConfig: () => { throw new Error('reward boom'); } }
71
+ : reward;
72
+ api.resolveOptional = (port) => (port === 'reward' ? rewardPort : null);
73
+ gc.listCategories = async () => categories;
74
+ api.projectSettings.set = async () => ({ key: 'attention.rot_days', value: '45' });
75
+
76
+ delete require.cache[require.resolve(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'))];
77
+ const router = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'))();
78
+ const restore = () => {
79
+ api.resolveRotDays = saved.resolveRotDays;
80
+ api.resolveOptional = saved.resolveOptional;
81
+ gc.listCategories = saved.list;
82
+ api.projectSettings.set = saved.set;
83
+ };
84
+ // The handler is the LAST layer on the matching route (requireBuilder and the
85
+ // permission gate sit in front); calling it directly is what keeps this a unit test
86
+ // of the payload rather than of the auth stack, which has its own tests.
87
+ const handlerFor = (method, routePath) => {
88
+ const layer = router.stack.find((l) => l.route && l.route.path === routePath && l.route.methods[method]);
89
+ assert.ok(layer, `no ${method.toUpperCase()} ${routePath}`);
90
+ const stack = layer.route.stack;
91
+ return stack[stack.length - 1].handle;
92
+ };
93
+ return { handlerFor, restore };
94
+ }
95
+
96
+ const TIMER_OK = { days: 30, source: 'default', invalid: null };
97
+ const CATS = [
98
+ { id: 1, slug: 'platform', label: 'Platform', sort_order: 10, active: true },
99
+ { id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
100
+ ];
101
+
102
+ test('one read returns all five fundamentals, each naming where its value came from', async () => {
103
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
104
+ try {
105
+ const res = fakeRes();
106
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
107
+ const rows = res.payload.rows;
108
+ assert.equal(rows.length, 5, 'the page draws five rows, so the read returns five');
109
+ assert.deepEqual(rows.map((r) => r.key),
110
+ ['rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
111
+ // Provenance is the point of the page: every row says where its value came from.
112
+ for (const r of rows) {
113
+ assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
114
+ assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
115
+ }
116
+ // Exactly two knobs — the criterion's "change the two that are knobs".
117
+ assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['rot_timer', 'work_categories']);
118
+ // A no-store receipt: a cached settings read is wrong the moment a knob turns.
119
+ assert.equal(res.headers['Cache-Control'], 'no-store');
120
+ } finally { restore(); }
121
+ });
122
+
123
+ test('an absent fundamental is reported absent, never as a plausible zero', async () => {
124
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
125
+ try {
126
+ const res = fakeRes();
127
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
128
+ const by = Object.fromEntries(res.payload.rows.map((r) => [r.key, r]));
129
+
130
+ assert.equal(by.upmarket_percent.state, 'absent');
131
+ assert.equal(by.upmarket_percent.value, undefined, 'an unconfigured knob must carry NO value');
132
+ assert.match(by.upmarket_percent.sentence, /no such setting yet/i);
133
+ assert.doesNotMatch(by.upmarket_percent.sentence, /\b0\s*%/, 'never render the absent knob as 0%');
134
+
135
+ assert.equal(by.equity_calculation.state, 'not-yet-real');
136
+ assert.match(by.equity_calculation.sentence, /no legal equity is issued/i);
137
+ assert.match(by.equity_calculation.sentence, /no cap table/i);
138
+ } finally { restore(); }
139
+ });
140
+
141
+ test('the credit row reports the formula in force, and says the estimate is not paid', async () => {
142
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
143
+ try {
144
+ const res = fakeRes();
145
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
146
+ const credit = res.payload.rows.find((r) => r.key === 'credit_calculation');
147
+ assert.equal(credit.editable, false);
148
+ assert.match(credit.sentence, /× 1\.20/, 'the multiplier comes from the resolved policy');
149
+ // cost-plus-only: the per-task estimate is advertised and never paid (ADR 0146).
150
+ assert.match(credit.sentence, /advertised guide only/i);
151
+ } finally { restore(); }
152
+ });
153
+
154
+ test('the credit row degrades named when the economy module is absent', async () => {
155
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS, reward: null });
156
+ try {
157
+ const res = fakeRes();
158
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
159
+ const credit = res.payload.rows.find((r) => r.key === 'credit_calculation');
160
+ assert.equal(credit.state, 'unavailable');
161
+ assert.equal(credit.value, undefined, 'no formula to report → no value');
162
+ assert.match(credit.sentence, /not enabled/i);
163
+ } finally { restore(); }
164
+ });
165
+
166
+ test('the credit row degrades named when rewardConfig() throws, rather than 500-ing the page', async () => {
167
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS, reward: 'throws' });
168
+ try {
169
+ const res = fakeRes();
170
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
171
+ // The whole read must still succeed — one bad lens degrades its own row, not the page.
172
+ assert.equal(res.code, null, 'the GET must not fail');
173
+ const credit = res.payload.rows.find((r) => r.key === 'credit_calculation');
174
+ assert.equal(credit.state, 'unavailable');
175
+ assert.equal(credit.value, undefined);
176
+ assert.match(credit.sentence, /could not be read/i);
177
+ } finally { restore(); }
178
+ });
179
+
180
+ test('the stale-timer row names the OTHER timer, so the page cannot be misread as configuring the sweep', async () => {
181
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
182
+ try {
183
+ const res = fakeRes();
184
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
185
+ const rot = res.payload.rows.find((r) => r.key === 'rot_timer');
186
+ assert.ok(rot.sibling, 'the claim sweep must be named on this row');
187
+ assert.equal(rot.sibling.reads.file, 'src/stale-timer.js');
188
+ assert.match(rot.sibling.sentence, /RELEASES it without asking/);
189
+ // The rot timer only ever ASKS — the reporting half of ADR 0226's asymmetry.
190
+ assert.match(rot.sentence, /ASKS a person/);
191
+ } finally { restore(); }
192
+ });
193
+
194
+ test('a configured-but-unusable timer is reported on screen, not thrown', async () => {
195
+ const invalid = { days: 30, source: 'default', invalid: { where: "project_settings['attention.rot_days']", raw: 'soon', reason: 'not a number' } };
196
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: invalid, categories: CATS });
197
+ try {
198
+ const res = fakeRes();
199
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
200
+ const rot = res.payload.rows.find((r) => r.key === 'rot_timer');
201
+ assert.equal(rot.invalid.raw, 'soon');
202
+ assert.equal(rot.value.days, 30, 'the fallback that is actually in force is still reported');
203
+ } finally { restore(); }
204
+ });
205
+
206
+ test('the timer write refuses a bad body in two layers, each with its own code', async () => {
207
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
208
+ const patch = handlerFor('patch', '/project-settings/rot-days');
209
+ try {
210
+ // Layer 1 — the strict ADR 0118 schema settles the TYPE and rejects unknown keys.
211
+ for (const bad of [{ days: 'soon' }, { days: '' }, { days: null }, {}, { days: 30, sneaky: 1 }]) {
212
+ const res = fakeRes();
213
+ await patch({ body: bad, builder: { id: 7 } }, res);
214
+ assert.equal(res.code, 'validation_failed', `accepted body ${JSON.stringify(bad)}`);
215
+ assert.equal(res.statusCode, 400);
216
+ }
217
+ // Layer 2 — the resolver's OWN parser settles the RANGE, so the form and the reader
218
+ // of the value can never disagree about what is acceptable.
219
+ for (const bad of [0, -3, 1.5, 400]) {
220
+ const res = fakeRes();
221
+ await patch({ body: { days: bad }, builder: { id: 7 } }, res);
222
+ assert.equal(res.code, 'bad_rot_days', `accepted ${JSON.stringify(bad)}`);
223
+ assert.equal(res.statusCode, 400);
224
+ }
225
+ } finally { restore(); }
226
+ });
227
+
228
+ test('a good timer write echoes what the system will now READ, not what was sent', async () => {
229
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: { days: 45, source: 'project_settings', invalid: null }, categories: CATS });
230
+ try {
231
+ const res = fakeRes();
232
+ await handlerFor('patch', '/project-settings/rot-days')({ body: { days: 45 }, builder: { id: 7 } }, res);
233
+ assert.equal(res.payload.ok, true);
234
+ assert.equal(res.payload.rot_days, 45);
235
+ assert.equal(res.payload.source, 'project_settings');
236
+ // The receipt carries provenance, never a bare number (ADR 0226).
237
+ assert.match(res.payload.describe, /45d \(project_settings\)/);
238
+ } finally { restore(); }
239
+ });
240
+
241
+ // ---- the category set's shape ----------------------------------------------
242
+
243
+ test('PATCH refuses a body that changes nothing rather than answering 200', async () => {
244
+ const handler = gc.categoryUpdateHandler({
245
+ update: async () => { throw new Error('must not be called'); },
246
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
247
+ parseId: () => 1, log: null,
248
+ });
249
+ const res = fakeRes();
250
+ await handler({ params: { id: '1' }, body: {} }, res);
251
+ assert.equal(res.code, 'nothing_to_update');
252
+ });
253
+
254
+ test('PATCH refuses a fractional sort_order instead of silently rounding it', async () => {
255
+ const handler = gc.categoryUpdateHandler({
256
+ update: async () => { throw new Error('must not be called'); },
257
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
258
+ parseId: () => 1, log: null,
259
+ });
260
+ const res = fakeRes();
261
+ await handler({ params: { id: '1' }, body: { sort_order: 2.5 } }, res);
262
+ assert.equal(res.code, 'bad_sort_order');
263
+ });
264
+
265
+ test('retiring a category is a flag, never a delete — the label stays resolvable', async () => {
266
+ let got = null;
267
+ const handler = gc.categoryUpdateHandler({
268
+ update: async (args) => { got = args; return { ...CATS[0], active: false }; },
269
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
270
+ parseId: () => 1, log: null,
271
+ });
272
+ const res = fakeRes();
273
+ await handler({ params: { id: '1' }, body: { active: false } }, res);
274
+ assert.equal(res.payload.category.active, false);
275
+ // Only what was sent travels: a retire must not blank the label by omission.
276
+ assert.equal(got.label, undefined);
277
+ assert.equal(got.sortOrder, undefined);
278
+ assert.equal(got.active, false);
279
+ });
280
+
281
+ test('a missing category is a 404, and it costs ONE query — no separate pre-read', async () => {
282
+ // updateCategory returns null for a row that is not there; that IS the 404, so the
283
+ // handler no longer issues a second read just to produce it (task 1003280 grade).
284
+ let updateCalls = 0;
285
+ const handler = gc.categoryUpdateHandler({
286
+ update: async () => { updateCalls += 1; return null; },
287
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
288
+ parseId: () => 999, log: null,
289
+ });
290
+ const res = fakeRes();
291
+ await handler({ params: { id: '999' }, body: { label: 'x' } }, res);
292
+ assert.equal(res.code, 'category_not_found');
293
+ assert.equal(res.statusCode, 404);
294
+ assert.equal(updateCalls, 1, 'exactly one PK query — the redundant pre-read is gone');
295
+ });
296
+
297
+ test('the slug is not patchable — it is the key goal prose refers to', () => {
298
+ const src = read('modules', 'lifecycle', 'goal-category.js');
299
+ const fn = src.slice(src.indexOf('async function updateCategory'));
300
+ const body = fn.slice(0, fn.indexOf('\n}'));
301
+ assert.doesNotMatch(body, /\bslug\s*=/, 'updateCategory must never assign slug');
302
+ });
303
+
304
+ // ---- the anti-drift rule (the one that makes this page believable) ----------
305
+
306
+ test('the ROUTE re-types no timer bound either — the drift the grade caught, pinned one layer up', () => {
307
+ // The major finding on this task's first grade: the page's anti-drift rule held on the
308
+ // CLIENT but the ROUTE re-typed `min: 1, max: 365` and the 400 message re-typed the
309
+ // range. The bounds live once, in src/bongos/project-settings.js. Assert the route
310
+ // carries neither literal in executable code.
311
+ const route = stripLineComments(read('modules', 'lifecycle', 'routes', 'project-settings.js'), '//');
312
+ assert.doesNotMatch(route, /\b365\b/, 'the route must read MAX_ROT_DAYS, not re-type 365');
313
+ assert.match(route, /api\.projectSettings\.(MIN|MAX)_ROT_DAYS/, 'the route reads the bounds from the single source');
314
+ });
315
+
316
+ test('the page re-types no formula, bound, default or file path from the server', () => {
317
+ const page = stripLineComments(read('modules', 'hall-ui', 'public', 'project-settings.js'), '//');
318
+ // The credit formula lives in economy and arrives as a sentence.
319
+ assert.doesNotMatch(page, /1\.2\b|\*\s*1\.20|marginPct\s*[/*]/, 'the client must not compute the reward');
320
+ // The rot bounds live in src/bongos/project-settings.js and arrive in row.value.
321
+ assert.doesNotMatch(page, /\b365\b/, 'the client must not re-type the timer ceiling');
322
+ // Provenance paths are server-supplied strings, never authored in the client.
323
+ assert.doesNotMatch(page, /src\/stale-timer\.js|core_226|core_231/, 'the client must not author provenance paths');
324
+ });
325
+
326
+ test('the page is gated, the atom exists, and a grant migration backs it', () => {
327
+ assert.match(read('tests', 'hall_page_gate_map.mjs'), /'\/builders\/project-settings': 'requireGovernmentPage'/);
328
+ assert.match(read('modules', 'government', 'catalog.js'), /key: 'project\.settings\.manage'/);
329
+ const grantSrc = read('modules', 'government', 'migrations', 'government_010_project_settings_manage.sql');
330
+ const grant = stripLineComments(grantSrc, '--');
331
+ // One block per rank — the drift guard scans for this literal shape.
332
+ assert.match(grant, /SELECT 'metic', unnest\(ARRAY\[\s*'project\.settings\.manage'/);
333
+ assert.match(grant, /SELECT 'archon', unnest\(ARRAY\[\s*'project\.settings\.manage'/);
334
+ // The renamed table, not the pre-government_001 one.
335
+ assert.match(grant, /INSERT INTO government_rank_permissions/);
336
+ assert.doesNotMatch(grant, /governance_role_permissions/);
337
+ });