@bongos/core 1.19.485 → 1.19.487
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 +78 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +148 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +64 -61
- package/docs/copy-registry.json +97 -70
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/modules/hall-ui/public/goal-inbox.js +119 -1
- package/modules/hall-ui/public/hall-render.js +7 -8
- package/modules/hall-ui/public/settings.js +8 -7
- package/modules/lifecycle/help-requests.js +3 -3
- package/modules/lifecycle/migrations/lifecycle_006_help_requests_ui_craft.sql +38 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/rot.js +210 -0
- package/modules/lifecycle/routes/rot.js +160 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/onboarding/newcomer-restock.js +53 -22
- package/modules/ui-design/module.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/newcomer-floor.config.json +2 -1
- package/scripts/gds/newcomer-floor.js +7 -1
- package/scripts/gds/newcomer-floor.templates.json +43 -1
- package/src/bongos/db.js +3 -3
- package/src/bongos/project-settings.js +193 -0
- package/src/bongos/routes/me.js +2 -1
- package/src/module-api.js +29 -1
- package/tests/help_requests.mjs +21 -2
- package/tests/module_api.mjs +1 -0
- package/tests/module_contributions.mjs +67 -0
- package/tests/modules.mjs +19 -1
- package/tests/newcomer_restock.mjs +76 -0
- package/tests/rot_inbox.mjs +532 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// tests/rot_inbox.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003279 (goal 1000072, criterion legibility-sort-and-staleness) — a goal or
|
|
4
|
+
// task left untouched past the project's rot timer raises ONE actionable line in the
|
|
5
|
+
// hall's needs-attention inbox, with working verbs, and acting on it removes the line.
|
|
6
|
+
//
|
|
7
|
+
// What this pins, in the order it matters:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE ANTI-DRIFT GUARDS. Two numbers and one SQL expression in this feature
|
|
10
|
+
// exist in more than one file by necessity, and each pairing is asserted rather
|
|
11
|
+
// than trusted:
|
|
12
|
+
// • the rot clock is spelled in rot.js AND in the index expressions of
|
|
13
|
+
// migration core_231 — if they diverge the query silently stops using the
|
|
14
|
+
// index, which nothing else would ever notice;
|
|
15
|
+
// • the clock's first two terms must equal the definition of "touched" that
|
|
16
|
+
// modules/hall-ui/public/task-lib.js already publishes for the boards
|
|
17
|
+
// (task 1003278), or the board and the inbox disagree about which rows are
|
|
18
|
+
// stale — the 6h-vs-24h failure of ADR 0226, one tier up;
|
|
19
|
+
// • every verb the server declares must name a route that actually exists.
|
|
20
|
+
// 2. The rot SET: what can rot, and — more importantly — what cannot. 'active' is
|
|
21
|
+
// excluded because the CLAIM timer owns it; a regression that re-includes it
|
|
22
|
+
// would put the same rot in two inboxes with two different numbers.
|
|
23
|
+
// 3. Idempotency by construction: there is no items table, so the line is derived,
|
|
24
|
+
// and watering/acting removes it. Proven by the SQL predicate, not by a sweep.
|
|
25
|
+
// 4. The timer resolver's precedence and its REPORTING fail posture: a bad value
|
|
26
|
+
// degrades to the default WITH a receipt, and an unreachable DB never takes the
|
|
27
|
+
// inbox down.
|
|
28
|
+
// 5. The gates: the read is own-scoped from the session; watering is refused to a
|
|
29
|
+
// stranger; a finished thing has no clock to reset.
|
|
30
|
+
//
|
|
31
|
+
// Pure — recording fake pools over the real domain and the real router, no Postgres,
|
|
32
|
+
// no network beyond a loopback listener. Runs in the DB-free unit gate.
|
|
33
|
+
//
|
|
34
|
+
// Run: node tests/rot_inbox.mjs
|
|
35
|
+
|
|
36
|
+
import { strict as assert } from 'node:assert';
|
|
37
|
+
import { test, after } from 'node:test';
|
|
38
|
+
import { createRequire } from 'node:module';
|
|
39
|
+
import http from 'node:http';
|
|
40
|
+
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
import { dirname, join } from 'node:path';
|
|
43
|
+
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
const express = require('express');
|
|
46
|
+
|
|
47
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
|
+
const MIGRATION_SRC = readFileSync(join(ROOT, 'migrations/core_231_project_settings_and_attention_water.sql'), 'utf8');
|
|
49
|
+
const TASK_LIB_SRC = readFileSync(join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
|
|
50
|
+
const GOAL_INBOX_SRC = readFileSync(join(ROOT, 'modules/hall-ui/public/goal-inbox.js'), 'utf8');
|
|
51
|
+
|
|
52
|
+
// ---- the recording fake pool ----------------------------------------------
|
|
53
|
+
let queued = [];
|
|
54
|
+
let calls = [];
|
|
55
|
+
let poolThrows = null;
|
|
56
|
+
const pool = {
|
|
57
|
+
query: async (text, params) => {
|
|
58
|
+
if (poolThrows) throw poolThrows;
|
|
59
|
+
calls.push({ text: String(text), params: params || [] });
|
|
60
|
+
return queued.shift() || { rows: [], rowCount: 0 };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
function expect(...responses) { queued = responses; calls = []; poolThrows = null; }
|
|
64
|
+
const lastCall = () => calls[calls.length - 1];
|
|
65
|
+
const sqlOf = (c) => c.text.replace(/\s+/g, ' ').trim();
|
|
66
|
+
const squash = (s) => String(s).replace(/\s+/g, ' ').trim();
|
|
67
|
+
|
|
68
|
+
// ---- core pinned BEFORE anything requires it -------------------------------
|
|
69
|
+
//
|
|
70
|
+
// project-settings reaches the pool through src/bongos/pool.js; the lifecycle rot
|
|
71
|
+
// domain reaches it through the published doorway. Both are pinned to the SAME fake
|
|
72
|
+
// object so one queue drives both halves of the feature.
|
|
73
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
74
|
+
require.cache[poolPath] = {
|
|
75
|
+
id: poolPath, filename: poolPath, loaded: true,
|
|
76
|
+
exports: { pool, instanceDbName: () => 'test' },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Destructured rather than namespaced for the same reason src/module-api.js is:
|
|
80
|
+
// knip attributes a named export to a destructured binding, not to a property read
|
|
81
|
+
// off a required namespace, and this file is one of its entry points.
|
|
82
|
+
const {
|
|
83
|
+
SETTING_KEYS, DEFAULT_ROT_DAYS, MIN_ROT_DAYS, MAX_ROT_DAYS,
|
|
84
|
+
parseDays, getSetting, setSetting, resolveRotDays, describeRotTimer,
|
|
85
|
+
} = require('../src/bongos/project-settings.js');
|
|
86
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
87
|
+
|
|
88
|
+
const SESSION = { id: 77, github_login: 'octo', rank: 'xenos' };
|
|
89
|
+
let seeAllGranted = false; // flipped per test — the roster-read atom
|
|
90
|
+
let governancePresent = true;
|
|
91
|
+
|
|
92
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
93
|
+
require.cache[moduleApiPath] = {
|
|
94
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
95
|
+
exports: {
|
|
96
|
+
pool,
|
|
97
|
+
parsePagination: helpers.parsePagination,
|
|
98
|
+
pageMeta: helpers.pageMeta,
|
|
99
|
+
parseId: helpers.parseId,
|
|
100
|
+
requireBuilder: (req, _res, next) => { req.builder = { ...SESSION }; next(); },
|
|
101
|
+
resolveRotDays: resolveRotDays,
|
|
102
|
+
projectSettings: {
|
|
103
|
+
KEYS: SETTING_KEYS,
|
|
104
|
+
get: getSetting,
|
|
105
|
+
set: setSetting,
|
|
106
|
+
describeRotTimer: describeRotTimer,
|
|
107
|
+
},
|
|
108
|
+
// The namespaced logger the route takes at require time. A no-op here so a
|
|
109
|
+
// deliberately-provoked error path (the fail-closed permission resolve, the 500
|
|
110
|
+
// handlers) does not spray pino JSON through the test output.
|
|
111
|
+
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
112
|
+
// The governance port, controllable — so the refusal is proven, not assumed.
|
|
113
|
+
resolveOptional: (name) => (
|
|
114
|
+
name === 'government' && governancePresent
|
|
115
|
+
? { builderHasPermissions: async () => seeAllGranted }
|
|
116
|
+
: null
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const rot = require('../modules/lifecycle/rot.js');
|
|
122
|
+
const buildRotRouter = require('../modules/lifecycle/routes/rot.js');
|
|
123
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
124
|
+
|
|
125
|
+
const app = express();
|
|
126
|
+
app.use(express.json());
|
|
127
|
+
app.use(attachFail);
|
|
128
|
+
app.use('/api/gds', buildRotRouter());
|
|
129
|
+
const server = app.listen(0, '127.0.0.1');
|
|
130
|
+
await new Promise((r) => server.once('listening', r));
|
|
131
|
+
const port = server.address().port;
|
|
132
|
+
after(() => server.close());
|
|
133
|
+
|
|
134
|
+
function call(method, path) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const req = http.request({ host: '127.0.0.1', port, method, path }, (res) => {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
res.on('data', (c) => chunks.push(c));
|
|
139
|
+
res.on('end', () => {
|
|
140
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
141
|
+
let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
142
|
+
resolve({ status: res.statusCode, json, text });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
req.on('error', reject);
|
|
146
|
+
req.end();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const OLD = '2026-01-01T00:00:00.000Z';
|
|
151
|
+
const rotRow = (over = {}) => ({
|
|
152
|
+
tier: 'task', id: '5', title: 'a quiet task', status: 'backlog',
|
|
153
|
+
goal_id: '1000072', goal_title: 'Human legibility', created_by: '77',
|
|
154
|
+
last_touched_at: OLD, ever_watered: false, ...over,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- 1. the anti-drift guards ----------------------------------------------
|
|
158
|
+
|
|
159
|
+
test('the rot clock in the domain is the SAME expression the migration indexes', () => {
|
|
160
|
+
// rot.js aliases the table as t / g; the migration indexes the bare columns. Strip
|
|
161
|
+
// the alias and compare the shapes — this is the pairing that silently stops using
|
|
162
|
+
// the index if it drifts, so it is asserted, not trusted.
|
|
163
|
+
const domainTask = squash(rot.TASK_ROT_CLOCK).replace(/\bt\./g, '');
|
|
164
|
+
const domainGoal = squash(rot.GOAL_ROT_CLOCK).replace(/\bg\./g, '');
|
|
165
|
+
const migration = squash(MIGRATION_SRC);
|
|
166
|
+
assert.ok(
|
|
167
|
+
migration.includes(domainTask),
|
|
168
|
+
`migration core_231 does not index the task rot clock the domain queries.\n domain: ${domainTask}\n migration: (not found)`
|
|
169
|
+
);
|
|
170
|
+
assert.ok(
|
|
171
|
+
migration.includes(domainGoal),
|
|
172
|
+
`migration core_231 does not index the goal rot clock the domain queries.\n domain: ${domainGoal}`
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("the clock's first terms ARE task-lib's published definition of touched, not a second one", () => {
|
|
177
|
+
// task-lib.js: taskActivityMs = Math.max(msOf(t.updated_at), msOf(t.shipped_at)).
|
|
178
|
+
// If that ever stops folding in shipped_at, or the server stops matching it, the
|
|
179
|
+
// board and the inbox disagree about which rows are stale.
|
|
180
|
+
assert.match(
|
|
181
|
+
TASK_LIB_SRC,
|
|
182
|
+
/taskActivityMs\s*=\s*\(t\)\s*=>\s*Math\.max\(\s*msOf\(t\.updated_at\)\s*,\s*msOf\(t\.shipped_at\)\s*\)/,
|
|
183
|
+
'task-lib.js no longer defines "touched" as max(updated_at, shipped_at) — the server clock in rot.js must be changed with it'
|
|
184
|
+
);
|
|
185
|
+
const clock = squash(rot.TASK_ROT_CLOCK);
|
|
186
|
+
assert.ok(clock.includes('t.updated_at'), 'the task clock must read updated_at');
|
|
187
|
+
assert.ok(clock.includes('t.shipped_at'), 'the task clock must fold in shipped_at, exactly as task-lib.js does');
|
|
188
|
+
assert.ok(clock.includes('t.attention_watered_at'), 'the task clock must fold in the water mark — that is what this task adds');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('every verb the server declares names a route that exists', () => {
|
|
192
|
+
const ROUTER_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/rot.js'), 'utf8');
|
|
193
|
+
const TASKS_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
194
|
+
const GOALS_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
|
|
195
|
+
const haystack = ROUTER_SRC + TASKS_SRC + GOALS_SRC;
|
|
196
|
+
for (const v of [...rot.TASK_VERBS, ...rot.GOAL_VERBS]) {
|
|
197
|
+
assert.ok(
|
|
198
|
+
haystack.includes(`'${v.path}'`),
|
|
199
|
+
`verb '${v.verb}' advertises ${v.method} ${v.path}, but no router declares that path — the inbox would render a button the server 404s`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('water is the ONLY verb this feature adds — the other three reuse existing routes', () => {
|
|
205
|
+
const ROUTER_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/rot.js'), 'utf8');
|
|
206
|
+
const declaredHere = [...ROUTER_SRC.matchAll(/router\.(get|post)\('([^']+)'/g)].map((m) => m[2]);
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
declaredHere.sort(),
|
|
209
|
+
['/goals/:id/water', '/inbox/rotting', '/tasks/:id/water'],
|
|
210
|
+
'rot.js grew a route beyond the one read and the two waters — the other verbs must reuse the routes that already do those jobs (the task-1003066 precedent)'
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---- 2. the rot set: what can rot, and what deliberately cannot -------------
|
|
215
|
+
|
|
216
|
+
test("'active' is NOT rottable — the claim timer owns a claim that went silent", () => {
|
|
217
|
+
assert.ok(!rot.TASK_ROTTABLE_STATUSES.includes('active'),
|
|
218
|
+
'a claimed-but-quiet task is the stale-CLAIM timer\'s question (src/stale-timer.js); reporting it here too is two inboxes with two different numbers');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('the rottable set is the queue nobody picked up — nothing terminal, nothing in flight', () => {
|
|
222
|
+
assert.deepEqual([...rot.TASK_ROTTABLE_STATUSES].sort(), ['backlog', 'ready']);
|
|
223
|
+
assert.deepEqual([...rot.GOAL_ROTTABLE_STATUSES], ['open']);
|
|
224
|
+
for (const terminal of ['shipped', 'abandoned', 'blocked', 'completed', 'confirmed']) {
|
|
225
|
+
assert.ok(!rot.TASK_ROTTABLE_STATUSES.includes(terminal), `${terminal} must not rot — another surface owns it`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the query filters on exactly that set, on both tiers', async () => {
|
|
230
|
+
expect({ rows: [] });
|
|
231
|
+
await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
232
|
+
const c = lastCall();
|
|
233
|
+
assert.deepEqual(c.params[1], rot.TASK_ROTTABLE_STATUSES);
|
|
234
|
+
assert.deepEqual(c.params[4], rot.GOAL_ROTTABLE_STATUSES);
|
|
235
|
+
assert.match(sqlOf(c), /t\.status = ANY\(\$2::text\[\]\)/);
|
|
236
|
+
assert.match(sqlOf(c), /g\.status = ANY\(\$5::text\[\]\)/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---- 3. derived, not swept: idempotency by construction ---------------------
|
|
240
|
+
|
|
241
|
+
test('the line is DERIVED — the read compares the clock to a cutoff, it does not read an items table', async () => {
|
|
242
|
+
expect({ rows: [] });
|
|
243
|
+
await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
244
|
+
const sql = sqlOf(lastCall());
|
|
245
|
+
assert.match(sql, /make_interval\(days => \$1::int\)/, 'the cutoff must come from the timer, not a hardcoded interval');
|
|
246
|
+
assert.ok(!/attention_items|rot_items|INSERT/i.test(sql),
|
|
247
|
+
'there must be no items table and no write on the read path — that is what makes "one line per stale thing, not one per sweep" true by construction');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the cutoff is the timer, so watering or editing removes the line with no sweep to run', async () => {
|
|
251
|
+
expect({ rows: [] });
|
|
252
|
+
await rot.rottingFor({ builderId: 77, rotDays: 7 });
|
|
253
|
+
assert.equal(lastCall().params[0], 7);
|
|
254
|
+
const sql = sqlOf(lastCall());
|
|
255
|
+
// The clock includes the water mark on both arms, which is the whole mechanism:
|
|
256
|
+
// push the mark forward and the row stops satisfying the predicate.
|
|
257
|
+
assert.ok(sql.includes('t.attention_watered_at'), 'watering a task must move it out of the result');
|
|
258
|
+
assert.ok(sql.includes('g.attention_watered_at'), 'watering a goal must move it out of the result');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('oldest first — the thing that has rotted longest is the first thing you see', async () => {
|
|
262
|
+
expect({ rows: [] });
|
|
263
|
+
await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
264
|
+
assert.match(sqlOf(lastCall()), /ORDER BY last_touched_at ASC/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('the page is capped — an unbounded rot list is a wall nobody reads', async () => {
|
|
268
|
+
expect({ rows: [] });
|
|
269
|
+
await rot.rottingFor({ builderId: 77, rotDays: 30, limit: 99999 });
|
|
270
|
+
assert.equal(lastCall().params[5], rot.MAX_ROT_ROWS);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('a non-positive builder never reaches SQL', async () => {
|
|
274
|
+
expect({ rows: [rotRow()] });
|
|
275
|
+
const got = await rot.rottingFor({ builderId: 0, rotDays: 30 });
|
|
276
|
+
assert.deepEqual(got.rows, []);
|
|
277
|
+
assert.equal(calls.length, 0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('a non-integer timer is refused rather than silently becoming a cutoff of NaN', async () => {
|
|
281
|
+
await assert.rejects(() => rot.rottingFor({ builderId: 77, rotDays: 'soon' }), /positive whole number/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---- 4. scope: who a rot line is addressed to ------------------------------
|
|
285
|
+
|
|
286
|
+
test('a plain builder sees only what they created', async () => {
|
|
287
|
+
expect({ rows: [] });
|
|
288
|
+
await rot.rottingFor({ builderId: 77, canSeeAll: false, rotDays: 30 });
|
|
289
|
+
const c = lastCall();
|
|
290
|
+
assert.equal(c.params[2], 77);
|
|
291
|
+
assert.equal(c.params[3], false);
|
|
292
|
+
assert.match(sqlOf(c), /\$4::boolean OR t\.created_by = \$3::bigint/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('canSeeAll widens the read without naming a builder in the request', async () => {
|
|
296
|
+
expect({ rows: [] });
|
|
297
|
+
await rot.rottingFor({ builderId: 77, canSeeAll: true, rotDays: 30 });
|
|
298
|
+
assert.equal(lastCall().params[3], true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('an unattributed thing is visible ONLY to canSeeAll — it has no creator to address', async () => {
|
|
302
|
+
// created_by IS NULL never satisfies `created_by = $3`, so the ownership arm
|
|
303
|
+
// excludes it and only the boolean arm can let it through. Pinning the predicate
|
|
304
|
+
// shape is what keeps orphaned rot from becoming everyone's problem.
|
|
305
|
+
expect({ rows: [] });
|
|
306
|
+
await rot.rottingFor({ builderId: 77, canSeeAll: false, rotDays: 30 });
|
|
307
|
+
const sql = sqlOf(lastCall());
|
|
308
|
+
assert.ok(!/created_by IS NULL/.test(sql),
|
|
309
|
+
'a NULL creator must not be OR-ed into every builder\'s inbox');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('each row carries the verbs for ITS tier, so the client never hardcodes the mapping', async () => {
|
|
313
|
+
expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9' })] });
|
|
314
|
+
const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
315
|
+
assert.deepEqual(got.rows[0].verbs.map((v) => v.verb), ['prioritise', 'prune', 'kill', 'water']);
|
|
316
|
+
assert.deepEqual(got.rows[1].verbs.map((v) => v.verb), ['kill', 'water']);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ---- 5. the timer resolver: precedence + the reporting fail posture ---------
|
|
320
|
+
|
|
321
|
+
test('no row anywhere = the documented default, and it says so', async () => {
|
|
322
|
+
expect({ rows: [] });
|
|
323
|
+
const got = await resolveRotDays();
|
|
324
|
+
assert.equal(got.days, DEFAULT_ROT_DAYS);
|
|
325
|
+
assert.equal(got.source, 'default');
|
|
326
|
+
assert.equal(got.invalid, null);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('a configured row IS the project answer, and the receipt names the store', async () => {
|
|
330
|
+
expect({ rows: [{ key: 'attention.rot_days', value: '45', updated_at: OLD, updated_by: '1' }] });
|
|
331
|
+
const got = await resolveRotDays();
|
|
332
|
+
assert.equal(got.days, 45);
|
|
333
|
+
assert.equal(got.source, 'project_settings');
|
|
334
|
+
assert.match(describeRotTimer(got), /45d \(project_settings\)/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('an override is a LENS — it wins, and it never reads the store at all', async () => {
|
|
338
|
+
expect({ rows: [{ key: 'attention.rot_days', value: '45' }] });
|
|
339
|
+
const got = await resolveRotDays({ override: '7' });
|
|
340
|
+
assert.equal(got.days, 7);
|
|
341
|
+
assert.equal(got.source, 'override');
|
|
342
|
+
assert.equal(calls.length, 0, 'an override must not even query the store');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('REPORTING DEGRADES: an unusable configured value falls back WITH a receipt, it does not throw', async () => {
|
|
346
|
+
expect({ rows: [{ key: 'attention.rot_days', value: '0' }] });
|
|
347
|
+
const got = await resolveRotDays();
|
|
348
|
+
assert.equal(got.days, DEFAULT_ROT_DAYS);
|
|
349
|
+
assert.equal(got.source, 'default');
|
|
350
|
+
assert.equal(got.invalid.reason, 'must be positive');
|
|
351
|
+
assert.match(describeRotTimer(got), /ignoring project_settings\['attention\.rot_days'\]="0"/);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('the range is enforced at BOTH ends, and a fractional day is refused', () => {
|
|
355
|
+
assert.equal(parseDays(String(MIN_ROT_DAYS)).days, MIN_ROT_DAYS);
|
|
356
|
+
assert.equal(parseDays(String(MAX_ROT_DAYS)).days, MAX_ROT_DAYS);
|
|
357
|
+
assert.match(parseDays(String(MAX_ROT_DAYS + 1)).reason, /out of range/);
|
|
358
|
+
assert.match(parseDays('1.5').reason, /whole number/);
|
|
359
|
+
assert.match(parseDays('soon').reason, /not a number/);
|
|
360
|
+
assert.equal(parseDays('').reason, 'empty');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('an unreachable database is not an invalid value — it degrades to the default, not a 500', async () => {
|
|
364
|
+
expect();
|
|
365
|
+
poolThrows = new Error('ECONNREFUSED');
|
|
366
|
+
const got = await resolveRotDays();
|
|
367
|
+
assert.equal(got.days, DEFAULT_ROT_DAYS);
|
|
368
|
+
assert.equal(got.source, 'default');
|
|
369
|
+
assert.equal(got.invalid, null, 'a read failure is not a misconfiguration — do not blame the operator for it');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('absence is meaningful: a never-set key reads back null rather than a seeded default', async () => {
|
|
373
|
+
expect({ rows: [] });
|
|
374
|
+
assert.equal(await getSetting('attention.rot_days'), null);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('setSetting upserts on the key so a knob has exactly one row', async () => {
|
|
378
|
+
expect({ rows: [{ key: 'attention.rot_days', value: '14' }] });
|
|
379
|
+
await setSetting('attention.rot_days', 14, 77);
|
|
380
|
+
const sql = sqlOf(lastCall());
|
|
381
|
+
assert.match(sql, /ON CONFLICT \(key\) DO UPDATE/);
|
|
382
|
+
assert.deepEqual(lastCall().params, ['attention.rot_days', '14', 77]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---- 6. the routes: gates and refusals -------------------------------------
|
|
386
|
+
|
|
387
|
+
test('GET /inbox/rotting takes its builder from the SESSION and reports the timer', async () => {
|
|
388
|
+
seeAllGranted = false;
|
|
389
|
+
expect({ rows: [] }, { rows: [rotRow()] }); // settings read, then the rot read
|
|
390
|
+
const res = await call('GET', '/api/gds/inbox/rotting');
|
|
391
|
+
assert.equal(res.status, 200);
|
|
392
|
+
assert.equal(res.json.scope, 'mine');
|
|
393
|
+
assert.equal(res.json.count, 1);
|
|
394
|
+
assert.equal(res.json.timer.rot_days, DEFAULT_ROT_DAYS);
|
|
395
|
+
assert.equal(res.json.timer.source, 'default');
|
|
396
|
+
assert.equal(res.json.cutoff != null, true, 'the cutoff is echoed so a reader can tell WHY a row is here');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('holding the roster-read atom widens the scope to all', async () => {
|
|
400
|
+
seeAllGranted = true;
|
|
401
|
+
expect({ rows: [] }, { rows: [] });
|
|
402
|
+
const res = await call('GET', '/api/gds/inbox/rotting');
|
|
403
|
+
assert.equal(res.json.scope, 'all');
|
|
404
|
+
seeAllGranted = false;
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('a rot read is never cached — a stale rot list is a wrong one the moment somebody waters a row', async () => {
|
|
408
|
+
expect({ rows: [] }, { rows: [] });
|
|
409
|
+
const res = await new Promise((resolve, reject) => {
|
|
410
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: '/api/gds/inbox/rotting' },
|
|
411
|
+
(r) => { r.resume(); r.on('end', () => resolve(r)); });
|
|
412
|
+
req.on('error', reject); req.end();
|
|
413
|
+
});
|
|
414
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('the creator can water their own rotting task', async () => {
|
|
418
|
+
seeAllGranted = false;
|
|
419
|
+
expect(
|
|
420
|
+
{ rows: [{ created_by: '77', status: 'backlog' }] },
|
|
421
|
+
{ rows: [{ id: '5', attention_watered_at: '2026-09-02T00:00:00.000Z', attention_watered_by: '77' }] }
|
|
422
|
+
);
|
|
423
|
+
const res = await call('POST', '/api/gds/tasks/5/water');
|
|
424
|
+
assert.equal(res.status, 200);
|
|
425
|
+
assert.equal(res.json.ok, true);
|
|
426
|
+
assert.equal(res.json.watered_at, '2026-09-02T00:00:00.000Z');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a stranger without the atom cannot water someone else\'s task — and nothing is written', async () => {
|
|
430
|
+
seeAllGranted = false;
|
|
431
|
+
expect({ rows: [{ created_by: '999', status: 'backlog' }] });
|
|
432
|
+
const res = await call('POST', '/api/gds/tasks/5/water');
|
|
433
|
+
assert.equal(res.status, 403);
|
|
434
|
+
assert.equal(res.json.error.code, 'permission_forbidden');
|
|
435
|
+
assert.equal(calls.length, 1, 'the refusal must come BEFORE the UPDATE, not after it');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('a builder holding the atom can water a task they did not create', async () => {
|
|
439
|
+
seeAllGranted = true;
|
|
440
|
+
expect(
|
|
441
|
+
{ rows: [{ created_by: '999', status: 'backlog' }] },
|
|
442
|
+
{ rows: [{ id: '5', attention_watered_at: OLD, attention_watered_by: '77' }] }
|
|
443
|
+
);
|
|
444
|
+
const res = await call('POST', '/api/gds/tasks/5/water');
|
|
445
|
+
assert.equal(res.status, 200);
|
|
446
|
+
seeAllGranted = false;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('authority that cannot be established is a denial — a missing governance port refuses', async () => {
|
|
450
|
+
governancePresent = false;
|
|
451
|
+
expect({ rows: [{ created_by: '999', status: 'backlog' }] });
|
|
452
|
+
const res = await call('POST', '/api/gds/tasks/5/water');
|
|
453
|
+
assert.equal(res.status, 403, 'ADR 0016 fail-closed: no port means no permission, never a pass');
|
|
454
|
+
governancePresent = true;
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('watering a finished thing is a 409, not a vacuous 200', async () => {
|
|
458
|
+
seeAllGranted = false;
|
|
459
|
+
expect(
|
|
460
|
+
{ rows: [{ created_by: '77', status: 'shipped' }] },
|
|
461
|
+
{ rows: [] } // the status-filtered UPDATE matches nothing
|
|
462
|
+
);
|
|
463
|
+
const res = await call('POST', '/api/gds/tasks/5/water');
|
|
464
|
+
assert.equal(res.status, 409);
|
|
465
|
+
assert.equal(res.json.error.code, 'not_rottable');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('watering something that does not exist is a 404', async () => {
|
|
469
|
+
expect({ rows: [] });
|
|
470
|
+
const res = await call('POST', '/api/gds/tasks/5/water');
|
|
471
|
+
assert.equal(res.status, 404);
|
|
472
|
+
assert.equal(res.json.error.code, 'task_not_found');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('a non-numeric id is refused before any lookup', async () => {
|
|
476
|
+
expect();
|
|
477
|
+
const res = await call('POST', '/api/gds/tasks/me/water');
|
|
478
|
+
assert.equal(res.status, 400);
|
|
479
|
+
assert.equal(calls.length, 0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('the goal water route exists and refuses a stranger the same way', async () => {
|
|
483
|
+
seeAllGranted = false;
|
|
484
|
+
expect({ rows: [{ created_by: '999', status: 'open' }] });
|
|
485
|
+
const res = await call('POST', '/api/gds/goals/9/water');
|
|
486
|
+
assert.equal(res.status, 403);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('the UPDATE is status-filtered, so a finished thing can never have its clock reset', async () => {
|
|
490
|
+
expect({ rows: [{ id: '5', attention_watered_at: OLD }] });
|
|
491
|
+
await rot.water('task', 5, 77);
|
|
492
|
+
const c = lastCall();
|
|
493
|
+
assert.match(sqlOf(c), /UPDATE tasks SET attention_watered_at = now\(\), attention_watered_by = \$2 WHERE id = \$1 AND status = ANY\(\$3::text\[\]\)/);
|
|
494
|
+
assert.deepEqual(c.params[2], rot.TASK_ROTTABLE_STATUSES);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('watering twice is not an error — a second look is a sensible thing for a human to do', async () => {
|
|
498
|
+
expect(
|
|
499
|
+
{ rows: [{ id: '5', attention_watered_at: '2026-09-01T00:00:00.000Z' }] },
|
|
500
|
+
{ rows: [{ id: '5', attention_watered_at: '2026-09-02T00:00:00.000Z' }] }
|
|
501
|
+
);
|
|
502
|
+
const first = await rot.water('task', 5, 77);
|
|
503
|
+
const second = await rot.water('task', 5, 77);
|
|
504
|
+
assert.ok(first && second, 'an already-watered thing waters again, it does not 409');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ---- 7. the client renders the card's contract, not its own ----------------
|
|
508
|
+
|
|
509
|
+
test('the slice lands in the EXISTING attention card — never a new Home card', () => {
|
|
510
|
+
assert.ok(GOAL_INBOX_SRC.includes("'<div id=\"attention-rot\"></div>'"),
|
|
511
|
+
'the rot slice must be a div inside the needs-attention card (hall-ui/CLAUDE.md: a second inbox splits attention)');
|
|
512
|
+
assert.ok(!/contributeHallWidget\([\s\S]*attention-rot/.test(GOAL_INBOX_SRC),
|
|
513
|
+
'the rot slice must not register a widget of its own');
|
|
514
|
+
assert.equal((GOAL_INBOX_SRC.match(/contributeHallWidget\(/g) || []).length, 1,
|
|
515
|
+
'goal-inbox.js still contributes exactly one widget');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('rot rows carry .ginbox__item, so the card\'s count-pill and visibility include them', () => {
|
|
519
|
+
assert.match(GOAL_INBOX_SRC, /function rotRowHtml[\s\S]*?class="ginbox__item"/,
|
|
520
|
+
'a rot row that does not carry the shared item class would be invisible to OTBAttention.refresh()');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('the client renders SERVER-declared verbs rather than a hardcoded button list', () => {
|
|
524
|
+
assert.match(GOAL_INBOX_SRC, /\(Array\.isArray\(r\.verbs\) \? r\.verbs : \[\]\)\.map/,
|
|
525
|
+
'the buttons must come from the row\'s verbs array — a hardcoded list is a second copy of the tier→verb mapping');
|
|
526
|
+
assert.match(GOAL_INBOX_SRC, /data-rot-path=/, 'each button carries the server-declared path');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('acting on a rot line refreshes the ROT slice, not the goals list', () => {
|
|
530
|
+
assert.match(GOAL_INBOX_SRC, /ROT_TOASTS\[verb\] \|\| 'Done\.', loadRot\)/,
|
|
531
|
+
'refreshing the wrong slice leaves the acted-on row sitting there — the task-1003066 lesson');
|
|
532
|
+
});
|