@bongos/core 1.24.10 → 1.24.11

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.
Files changed (33) hide show
  1. package/.bongos-core.json +55 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +10 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +132 -5
  8. package/docs/api-reference.md +10 -3
  9. package/docs/architecture.md +4 -0
  10. package/docs/copy-inventory.md +65 -59
  11. package/docs/copy-registry.json +123 -69
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +33 -33
  14. package/modules/government/decision-rules.js +1 -0
  15. package/modules/hall-ui/public/project-settings.css +12 -0
  16. package/modules/hall-ui/public/project-settings.js +63 -1
  17. package/modules/hall-ui/public/task.js +34 -1
  18. package/modules/lifecycle/migrations/lifecycle_018_pay_terms.sql +64 -0
  19. package/modules/lifecycle/pay-terms.js +171 -0
  20. package/modules/lifecycle/routes/project-settings.js +94 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +35 -4
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/release-notes.json +6 -0
  25. package/scripts/bongos/government-walk.js +1 -0
  26. package/scripts/bongos/run-unit-tests.js +4 -0
  27. package/scripts/bongos/start.js +42 -2
  28. package/src/module-api.js +1 -1
  29. package/tests/government_act_alone_buildings.mjs +1 -0
  30. package/tests/patch_gate_ordering.mjs +3 -0
  31. package/tests/pay_terms.mjs +254 -0
  32. package/tests/pay_terms_db.mjs +86 -0
  33. package/tests/project_settings_page.mjs +25 -6
@@ -0,0 +1,254 @@
1
+ // tests/pay_terms.mjs — a task carries the project's own pay terms, and a builder
2
+ // sees them before claiming (task 1004990, goal 1000092; ADR 0385 decision 3).
3
+ //
4
+ // What is real: modules/lifecycle/pay-terms.js (the checker, the writer, the
5
+ // reads), the lifecycle tasks router (PATCH /tasks/:id, GET /tasks/:id,
6
+ // GET /pay-terms), the project-settings router (PATCH /project-settings/pay-terms
7
+ // and the settings row), and `bongos start`'s pay line. What is faked: the caller
8
+ // (requireBuilder), the permission check — keyed on the ATOM each route asks for,
9
+ // so a case can withhold exactly that one — and Postgres, a recording stand-in
10
+ // that keeps the pay_term_sets / pay_terms history in memory.
11
+ //
12
+ // Run: node tests/pay_terms.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ const require = createRequire(import.meta.url);
17
+ process.env.NODE_ENV = 'test';
18
+
19
+ // ── the stand-in Postgres: a history of sets, nothing ever updated ────────────
20
+ let sets = [];
21
+ let terms = [];
22
+ let statements = [];
23
+ const query = async (sql, params = []) => {
24
+ statements.push(sql);
25
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
26
+ if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
27
+ const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date(Date.UTC(2026, 9, 30, 0, sets.length)) };
28
+ sets.push(row);
29
+ return { rows: [row] };
30
+ }
31
+ if (/INSERT INTO lifecycle_pay_terms/.test(sql)) {
32
+ // The writer sends one statement of parallel arrays (unnest WITH ORDINALITY).
33
+ const [set_id, types, amounts, units, notes] = params;
34
+ types.forEach((pay_type, i) => terms.push({ set_id, position: i, pay_type, amount: amounts[i], unit: units[i], note: notes[i] }));
35
+ return { rows: [] };
36
+ }
37
+ if (/FROM lifecycle_pay_term_sets s LEFT JOIN builders/.test(sql)) {
38
+ const rows = sets.filter((s) => s.task_id == null).sort((a, b) => b.id - a.id)
39
+ .slice(0, params[0]).map((s) => ({ ...s, set_by_login: 'owner' }));
40
+ return { rows };
41
+ }
42
+ if (/FROM lifecycle_pay_term_sets/.test(sql)) {
43
+ const ids = params[0].map(String);
44
+ const want = sets.filter((s) => s.task_id == null || ids.includes(String(s.task_id)));
45
+ const newest = new Map();
46
+ for (const s of want) newest.set(String(s.task_id), s); // ids ascend, so the last is newest
47
+ return { rows: [...newest.values()] };
48
+ }
49
+ if (/FROM lifecycle_pay_terms/.test(sql)) {
50
+ const ids = params[0].map(String);
51
+ return { rows: terms.filter((t) => ids.includes(String(t.set_id))).sort((a, b) => a.set_id - b.set_id || a.position - b.position) };
52
+ }
53
+ return { rows: [] };
54
+ };
55
+ const pool = { query, connect: async () => ({ query, release() {} }) };
56
+ const poolPath = require.resolve('../src/bongos/pool.js');
57
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
58
+
59
+ // ── the doorway: the caller, and a permission check keyed on the atom ─────────
60
+ let held = [];
61
+ const api = require('../src/module-api.js');
62
+ const set = (k, v) => Object.defineProperty(api, k, { value: v, configurable: true, writable: true });
63
+ set('requireBuilder', (req, _res, next) => { req.builder = { id: 7, rank: 'metic' }; next(); });
64
+ set('requirePermission', (atom) => (req, res, next) => (held.includes(atom) ? next() : res.fail('permission_denied', { status: 403, message: `needs ${atom}` })));
65
+ set('requireActAlone', () => (_req, _res, next) => next());
66
+ set('withTx', async (fn) => fn({ query }));
67
+
68
+ const db = require('../modules/lifecycle/db.js');
69
+ const TASK = { id: 5150, title: 'a task', status: 'ready', version_id: 'BONGOS-V2' };
70
+ db.getTask = async (id) => (Number(id) === TASK.id ? { ...TASK } : null);
71
+ db.getTaskDependencies = async () => [];
72
+ db.getTaskDependents = async () => [];
73
+
74
+ const lib = require('../modules/lifecycle/pay-terms.js');
75
+ const start = require('../scripts/bongos/start.js');
76
+
77
+ const express = require('express');
78
+ const app = express();
79
+ app.use(express.json());
80
+ app.use((req, res, next) => {
81
+ res.fail = (code, statusOrOpts) => {
82
+ const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
83
+ return res.status(o.status || 400).json({ error: code, message: o.message });
84
+ };
85
+ next();
86
+ });
87
+ app.use('/api', require('../modules/lifecycle/routes/tasks.js')());
88
+ app.use('/api', require('../modules/lifecycle/routes/project-settings.js')());
89
+ const server = app.listen(0);
90
+ await new Promise((r) => server.once('listening', r));
91
+ const base = `http://127.0.0.1:${server.address().port}/api`;
92
+ const call = async (method, path, body) => {
93
+ const r = await fetch(base + path, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
94
+ return { status: r.status, body: await r.json().catch(() => null) };
95
+ };
96
+ const reset = () => { sets = []; terms = []; statements = []; held = []; };
97
+
98
+ let passed = 0;
99
+ let failed = 0;
100
+ async function t(name, fn) {
101
+ reset();
102
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
103
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack}`); }
104
+ }
105
+
106
+ console.log('pay terms (task 1004990):');
107
+
108
+ await t('the checker takes exactly the six types, zero or more, and fills nothing in', () => {
109
+ assert.deepEqual(lib.PAY_TYPES, ['cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed']);
110
+ assert.deepEqual(lib.normalizePayTerms([]), { terms: [] }, 'zero terms is a real answer');
111
+ const all = lib.normalizePayTerms(lib.PAY_TYPES.map((p) => ({ pay_type: p })));
112
+ assert.equal(all.terms.length, 6);
113
+ for (const term of all.terms) {
114
+ assert.deepEqual([term.amount, term.unit, term.note], [null, null, null], `${term.pay_type}: nothing stated stays nothing — no default amount`);
115
+ }
116
+ const stated = lib.normalizePayTerms([{ pay_type: 'cash', amount: ' 500 ', unit: 'USD', note: '' }]).terms[0];
117
+ assert.deepEqual(stated, { pay_type: 'cash', amount: '500', unit: 'USD', note: null }, 'the project\'s words, trimmed, never converted');
118
+ for (const bad of [null, {}, 'cash', [{ pay_type: 'eu_labour' }], [{ pay_type: 'cash', amount: 500 }],
119
+ [{ pay_type: 'cash', rate: '1' }], [{}], new Array(11).fill({ pay_type: 'cash' }), [{ pay_type: 'cash', note: 'x'.repeat(501) }]]) {
120
+ assert.ok(lib.normalizePayTerms(bad).error, `refused: ${JSON.stringify(bad).slice(0, 60)}`);
121
+ }
122
+ });
123
+
124
+ await t('a self-managed term always carries the "Bongos does not move or guarantee" label', () => {
125
+ assert.equal(lib.SELF_MANAGED_LABEL, 'Paid by the project on its own systems. Bongos does not move or guarantee this money.');
126
+ assert.equal(lib.displayTerm({ pay_type: 'self_managed' }).label, lib.SELF_MANAGED_LABEL);
127
+ for (const p of lib.PAY_TYPES.filter((x) => x !== 'self_managed')) assert.equal(lib.displayTerm({ pay_type: p }).label, null);
128
+ });
129
+
130
+ await t('PATCH /tasks/:id pay_terms: REFUSED without task.edit, and nothing is written', async () => {
131
+ held = ['project.settings.manage'];
132
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
133
+ assert.equal(r.status, 403);
134
+ assert.match(r.body.message, /task\.edit/);
135
+ assert.deepEqual([sets.length, terms.length], [0, 0], 'no set, no term');
136
+ });
137
+
138
+ await t('PATCH /tasks/:id pay_terms: someone who may edit the task sets them, as a new set in the history', async () => {
139
+ held = ['task.edit'];
140
+ const first = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }, { pay_type: 'self_managed', note: 'paid monthly' }] });
141
+ assert.equal(first.status, 200, JSON.stringify(first.body));
142
+ assert.equal(first.body.task.id, TASK.id);
143
+ assert.equal(first.body.pay_terms.terms.length, 2);
144
+ assert.equal(first.body.pay_terms.terms[1].label, lib.SELF_MANAGED_LABEL);
145
+ const second = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [] });
146
+ assert.equal(second.status, 200);
147
+ assert.equal(sets.length, 2, 'the old set stays; a change is a new set');
148
+ assert.ok(sets.every((s) => s.set_by === 7), 'each set names who set it');
149
+ assert.ok(!statements.some((s) => /UPDATE lifecycle_pay_|DELETE FROM lifecycle_pay_/.test(s)), 'nothing is ever edited or deleted');
150
+ const bad = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'wages' }] });
151
+ assert.equal(bad.status, 400);
152
+ assert.equal(bad.body.error, 'bad_pay_terms');
153
+ const gone = await call('PATCH', '/tasks/999', { pay_terms: [] });
154
+ assert.equal(gone.status, 404);
155
+ assert.equal(sets.length, 2, 'a refused or missing task writes nothing');
156
+ });
157
+
158
+ await t('POST /tasks does not take pay terms: create is task.create, and only an editor sets pay', async () => {
159
+ held = ['task.create'];
160
+ const r = await call('POST', '/tasks', { title: 'x', version_id: 'BONGOS-V2', pay_terms: [{ pay_type: 'cash', amount: '1' }] });
161
+ assert.equal(r.status, 400, JSON.stringify(r.body));
162
+ assert.equal(r.body.error, 'validation_failed', 'the strict validator refuses the field');
163
+ // ...and it is the FIELD that is refused: the same body without it gets past validation.
164
+ const without = await call('POST', '/tasks', { title: 'x', version_id: 'BONGOS-V2' });
165
+ assert.notEqual(without.body && without.body.error, 'validation_failed', JSON.stringify(without.body));
166
+ assert.equal(sets.length, 0);
167
+ });
168
+
169
+ await t('a builder sees the terms BEFORE claiming: GET /pay-terms, the read the hall task page and bongos start make', async () => {
170
+ held = ['task.edit', 'project.settings.manage'];
171
+ const one = async () => (await call('GET', `/pay-terms?task_ids=${TASK.id}`)).body.pay_terms[String(TASK.id)];
172
+ assert.equal((await one()).source, 'none', 'nothing stated anywhere: nothing shown, no amount suggested');
173
+ // The project's standing terms first, then this task's own.
174
+ assert.equal((await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'equity', note: 'per the project agreement' }] })).status, 200);
175
+ let read = await one();
176
+ assert.equal(read.source, 'project', 'a task with none of its own shows the project\'s standing terms');
177
+ assert.equal(read.terms[0].pay_type, 'equity');
178
+ await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
179
+ read = await one();
180
+ assert.equal(read.source, 'task');
181
+ assert.deepEqual(read.terms.map((x) => [x.pay_type, x.amount, x.unit]), [['cash', '500', 'USD']]);
182
+ // A task the project set to "no pay terms" says so; the standing terms do not paper over it.
183
+ await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [] });
184
+ const list = await call('GET', `/pay-terms?task_ids=${TASK.id},77`);
185
+ assert.equal(list.status, 200);
186
+ assert.deepEqual([list.body.pay_terms[String(TASK.id)].source, list.body.pay_terms[String(TASK.id)].terms.length], ['task', 0]);
187
+ assert.equal(list.body.pay_terms['77'].source, 'project');
188
+ for (const q of ['', 'x', '1;2', new Array(51).fill(1).join(',')]) {
189
+ assert.equal((await call('GET', `/pay-terms?task_ids=${q}`)).status, 400, `task_ids=${q.slice(0, 20)}`);
190
+ }
191
+ });
192
+
193
+ await t('the project\'s pay policy: changed on the settings page, with who/when kept, REFUSED without the settings atom', async () => {
194
+ held = ['task.edit'];
195
+ const refused = await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'cash' }] });
196
+ assert.equal(refused.status, 403);
197
+ assert.equal(sets.length, 0);
198
+ held = ['project.settings.manage'];
199
+ await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'cash', amount: '20', unit: 'USD per hour' }] });
200
+ const second = await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'non_monetary', note: 'a mention in the credits' }] });
201
+ assert.equal(second.status, 200);
202
+ const row = second.body.row;
203
+ assert.equal(row.key, 'pay_terms');
204
+ assert.equal(row.editable, true);
205
+ assert.equal(row.value.terms[0].pay_type, 'non_monetary', 'the newest set is current');
206
+ assert.equal(row.value.history.length, 2, 'the audit trail: every change, newest first');
207
+ assert.deepEqual(row.value.history.map((h) => h.set_by_login), ['owner', 'owner']);
208
+ assert.ok(row.value.history[0].set_at, 'and when');
209
+ assert.deepEqual(row.value.types.map((x) => x.type), lib.PAY_TYPES);
210
+ held = ['project.settings.manage'];
211
+ const page = await call('GET', '/project-settings');
212
+ assert.ok(page.body.rows.some((r) => r.key === 'pay_terms'), 'the settings page draws the row');
213
+ });
214
+
215
+ await t('bongos start shows the pay line, the project\'s words only, with the self-managed label', async () => {
216
+ const view = {
217
+ source: 'task',
218
+ terms: [lib.displayTerm({ pay_type: 'cash', amount: '500', unit: 'USD' }), lib.displayTerm({ pay_type: 'self_managed' })],
219
+ };
220
+ const line = start.payTermsLine(view);
221
+ assert.match(line, /Cash: 500 USD/);
222
+ assert.match(line, /Paid by the project itself: amount not stated — Paid by the project on its own systems\. Bongos does not move or guarantee this money\./);
223
+ assert.equal(start.payTermsLine({ source: 'none', terms: [] }), null);
224
+ assert.match(start.payTermsLine({ source: 'project', terms: [lib.displayTerm({ pay_type: 'equity' })] }), /^project terms · /);
225
+ // The CLI asks for the whole list in one read, and an older server degrades to no line.
226
+ const asked = [];
227
+ const fakeApi = { payTerms: { getPayTerms: async (a) => { asked.push(a); return { ok: true, data: { pay_terms: { 1: view } } }; } } };
228
+ const rows = await start.withPayTerms(fakeApi, [{ id: 1 }, { id: 2 }]);
229
+ assert.deepEqual(asked, [{ query: { task_ids: '1,2' } }]);
230
+ assert.equal(rows[0].pay_terms, view);
231
+ assert.equal(rows[1].pay_terms, undefined);
232
+ assert.deepEqual(await start.withPayTerms({}, [{ id: 1 }]), [{ id: 1 }], 'no route, no change');
233
+ });
234
+
235
+ await t('the hall task page draws the terms verbatim and the self-managed label (the real payTermsHtml)', async () => {
236
+ const fs = require('node:fs');
237
+ const path = require('node:path');
238
+ const src = fs.readFileSync(path.join(process.cwd(), 'modules/hall-ui/public/task.js'), 'utf8');
239
+ const at = src.indexOf(' function payTermsHtml(view) {');
240
+ const end = src.indexOf('\n }\n', at);
241
+ assert.ok(at > 0 && end > at);
242
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
243
+ const payTermsHtml = new Function('escapeHtml', `${src.slice(at, end + 4)}; return payTermsHtml;`)(esc);
244
+ const html = payTermsHtml({ source: 'project', terms: [lib.displayTerm({ pay_type: 'self_managed', amount: '<b>9</b>' }), lib.displayTerm({ pay_type: 'cash' })] });
245
+ assert.match(html, /Paid by the project on its own systems\. Bongos does not move or guarantee this money\./);
246
+ assert.match(html, /&lt;b&gt;9&lt;\/b&gt;/, 'escaped, verbatim');
247
+ assert.match(html, /amount not stated/);
248
+ assert.match(html, /standing terms/);
249
+ assert.equal(payTermsHtml({ source: 'none', terms: [] }), '', 'nothing stated draws nothing');
250
+ });
251
+
252
+ server.close();
253
+ console.log(`\npay_terms: ${passed} passed, ${failed} failed`);
254
+ if (failed) process.exit(1);
@@ -0,0 +1,86 @@
1
+ // tests/pay_terms_db.mjs — task 1004990 (ADR 0385 D3). Real DB.
2
+ // The pay terms tables built from the real migration (lifecycle_018) in a throwaway
3
+ // schema, written and read through the real modules/lifecycle/pay-terms.js, proving
4
+ // the DATABASE holds the history: an unknown pay type, an over-long amount and a
5
+ // position past ten are refused; any UPDATE or DELETE is refused; and the writer's
6
+ // one-statement insert and the reads' SQL (the newest set per task, the project's standing set, the history with
7
+ // who set it) does what the DB-free tests/pay_terms.mjs assumes.
8
+ // Self-skips without a reachable Postgres.
9
+ // Run: DATABASE_URL=... node tests/pay_terms_db.mjs
10
+ import { createRequire } from 'node:module';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import { strict as assert } from 'node:assert';
15
+ const require = createRequire(import.meta.url);
16
+ const { Pool } = require('pg');
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+
19
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
20
+ let client;
21
+ try { client = await pgPool.connect(); } catch (e) {
22
+ console.log(`pay_terms_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
23
+ await pgPool.end().catch(() => {});
24
+ process.exit(0);
25
+ }
26
+ const SCHEMA = `t1004990_${process.pid}`;
27
+ let pass = 0;
28
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
29
+ const refused = async (sql, params, m) => {
30
+ await client.query('SAVEPOINT s');
31
+ let threw = false;
32
+ try { await client.query(sql, params); } catch { threw = true; }
33
+ await client.query('ROLLBACK TO SAVEPOINT s');
34
+ ok(threw, m);
35
+ };
36
+
37
+ try {
38
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
39
+ await client.query(`SET search_path TO ${SCHEMA}`);
40
+ await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
41
+ await client.query('CREATE TABLE tasks (id bigserial PRIMARY KEY)');
42
+ await client.query(readFileSync(join(here, '..', 'modules', 'lifecycle', 'migrations', 'lifecycle_018_pay_terms.sql'), 'utf8'));
43
+ await client.query("INSERT INTO builders (github_login) VALUES ('owner'), ('lead')");
44
+ await client.query('INSERT INTO tasks DEFAULT VALUES'); // 1
45
+ await client.query('INSERT INTO tasks DEFAULT VALUES'); // 2
46
+ await client.query('BEGIN');
47
+
48
+ const lib = require('../modules/lifecycle/pay-terms.js');
49
+ const o = { db: client };
50
+ const norm = (list) => lib.normalizePayTerms(list).terms;
51
+
52
+ await lib.setPayTerms({ taskId: null, terms: norm([{ pay_type: 'equity', note: 'per the agreement' }]), builderId: 1 }, o);
53
+ await lib.setPayTerms({ taskId: 1, terms: norm([{ pay_type: 'cash', amount: '500', unit: 'USD' }, { pay_type: 'self_managed' }]), builderId: 2 }, o);
54
+ let map = await lib.readPayTerms([1, 2], o);
55
+ ok(map.get('1').source === 'task' && map.get('1').terms.length === 2, 'a task reads its own newest set, in order');
56
+ ok(map.get('1').terms[1].label === lib.SELF_MANAGED_LABEL, 'the self-managed term carries its label');
57
+ ok(map.get('2').source === 'project' && map.get('2').terms[0].pay_type === 'equity', 'a task with none reads the standing terms');
58
+ await lib.setPayTerms({ taskId: 1, terms: [], builderId: 1 }, o);
59
+ map = await lib.readPayTerms([1], o);
60
+ ok(map.get('1').source === 'task' && map.get('1').terms.length === 0, 'an empty newest set means "no pay terms", not the standing ones');
61
+ const policy = await lib.readProjectPayTerms(o);
62
+ ok(policy.history.length === 1 && policy.history[0].set_by_login === 'owner', 'the history names who set the project terms');
63
+
64
+ const { rows: [aTerm] } = await client.query('SELECT id FROM lifecycle_pay_terms LIMIT 1');
65
+ const { rows: [aSet] } = await client.query('SELECT id FROM lifecycle_pay_term_sets LIMIT 1');
66
+ await refused('UPDATE lifecycle_pay_terms SET amount = $1 WHERE id = $2', ['9999', aTerm.id], 'a term cannot be edited');
67
+ await refused('DELETE FROM lifecycle_pay_terms WHERE id = $1', [aTerm.id], 'a term cannot be deleted');
68
+ await refused('UPDATE lifecycle_pay_term_sets SET set_by = 2 WHERE id = $1', [aSet.id], 'who set a set cannot be rewritten');
69
+ await refused('DELETE FROM lifecycle_pay_term_sets WHERE id = $1', [aSet.id], 'a set cannot be deleted');
70
+ await refused("INSERT INTO lifecycle_pay_terms (set_id, position, pay_type) VALUES ($1, 5, 'wages')", [aSet.id], 'an unknown pay type is refused');
71
+ await refused("INSERT INTO lifecycle_pay_terms (set_id, position, pay_type, amount) VALUES ($1, 6, 'cash', $2)", [aSet.id, 'x'.repeat(65)], 'an amount over 64 characters is refused');
72
+ await refused("INSERT INTO lifecycle_pay_terms (set_id, position, pay_type) VALUES ($1, 10, 'cash')", [aSet.id], 'an eleventh position is refused');
73
+ await refused("INSERT INTO lifecycle_pay_terms (set_id, position, pay_type, amount) VALUES ($1, 7, 'cash', '')", [aSet.id], 'an empty amount is refused: absent is NULL, never a blank');
74
+ const { rows: defaults } = await client.query(
75
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = 'lifecycle_pay_terms' AND column_default IS NOT NULL AND column_name <> 'id'", [SCHEMA]);
76
+ ok(defaults.length === 0, 'no pay_terms column has a default: Bongos suggests no amount');
77
+
78
+ await client.query('ROLLBACK');
79
+ console.log(`pay_terms_db: ${pass} passed`);
80
+ } finally {
81
+ await client.query('ROLLBACK').catch(() => {});
82
+ await client.query('SET search_path TO public').catch(() => {});
83
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
84
+ client.release();
85
+ await pgPool.end();
86
+ }
@@ -58,7 +58,8 @@ const fakeRes = () => ({
58
58
  // the two calls that would reach Postgres are swapped. Stubbing the row builders
59
59
  // instead would prove nothing about the shape the page actually receives.
60
60
  const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, costScale: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
61
- function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
61
+ const payTerms = require(path.join(ROOT, 'modules', 'lifecycle', 'pay-terms.js'));
62
+ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD, payPolicy = { terms: [], history: [] } }) {
62
63
  const saved = {
63
64
  resolveRotDays: api.resolveRotDays,
64
65
  resolveOptional: api.resolveOptional,
@@ -77,6 +78,9 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
77
78
  api.resolveOptional = (port) => (port === 'reward' ? rewardPort : null);
78
79
  gc.listCategories = async () => categories;
79
80
  api.projectSettings.set = async () => ({ key: 'attention.rot_days', value: '45' });
81
+ // task 1004990: the project's standing pay terms, read from their own table.
82
+ saved.readPay = payTerms.readProjectPayTerms;
83
+ payTerms.readProjectPayTerms = async () => (payPolicy instanceof Error ? Promise.reject(payPolicy) : payPolicy);
80
84
 
81
85
  delete require.cache[require.resolve(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'))];
82
86
  const router = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'))();
@@ -87,6 +91,7 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
87
91
  api.projectSettings.set = saved.set;
88
92
  api.projectSettings.resolveShipMode = saved.resolveShipMode;
89
93
  api.projectSettings.resolveTweakAutopickup = saved.resolveTweak;
94
+ payTerms.readProjectPayTerms = saved.readPay;
90
95
  };
91
96
  // The handler is the LAST layer on the matching route (requireBuilder and the
92
97
  // permission gate sit in front); calling it directly is what keeps this a unit test
@@ -106,22 +111,36 @@ const CATS = [
106
111
  { id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
107
112
  ];
108
113
 
109
- test('one read returns all seven fundamentals, each naming where its value came from', async () => {
114
+ test('an unreadable pay terms table degrades that row, named, and the page still draws (task 1004990)', async () => {
115
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS, payPolicy: new Error('pay boom') });
116
+ try {
117
+ const res = fakeRes();
118
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
119
+ assert.equal(res.statusCode, 200);
120
+ const pay = res.payload.rows.find((r) => r.key === 'pay_terms');
121
+ assert.equal(pay.state, 'unavailable');
122
+ assert.equal(pay.editable, false, 'nothing to edit over a set it could not read');
123
+ assert.equal(res.payload.rows.length, 8);
124
+ } finally { restore(); }
125
+ });
126
+
127
+ test('one read returns all eight fundamentals, each naming where its value came from', async () => {
110
128
  const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
111
129
  try {
112
130
  const res = fakeRes();
113
131
  await handlerFor('get', '/project-settings')({ query: {} }, res);
114
132
  const rows = res.payload.rows;
115
- assert.equal(rows.length, 7, 'the page draws seven rows, so the read returns seven');
133
+ assert.equal(rows.length, 8, 'the page draws eight rows, so the read returns eight');
116
134
  assert.deepEqual(rows.map((r) => r.key),
117
- ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
135
+ ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories', 'pay_terms', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
118
136
  // Provenance is the point of the page: every row says where its value came from.
119
137
  for (const r of rows) {
120
138
  assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
121
139
  assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
122
140
  }
123
- // Four knobs: the criterion's two, ship mode (task 1004628) and the monthly tweak-round cap (task 1005003).
124
- assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories']);
141
+ // Five knobs: the criterion's two, ship mode (task 1004628), the monthly tweak-round cap
142
+ // (task 1005003) and the project's standing pay terms (task 1004990).
143
+ assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories', 'pay_terms']);
125
144
  // A no-store receipt: a cached settings read is wrong the moment a knob turns.
126
145
  assert.equal(res.headers['Cache-Control'], 'no-store');
127
146
  } finally { restore(); }