@bongos/core 1.20.5 → 1.20.7
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 +113 -53
- package/.claude/skills/goal-review/SKILL.md +16 -24
- package/.claude/skills/goal-uat/SKILL.md +84 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +18 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0183-criteria-close-themselves.md +1 -1
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
- package/docs/adr/README.md +28 -0
- package/docs/api/openapi.json +385 -5
- package/docs/api-reference.md +14 -4
- package/docs/architecture.md +7 -0
- package/docs/copy-inventory.md +22 -22
- package/docs/copy-registry.json +23 -23
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/goals-page.js +8 -3
- package/modules/hall-ui/public/tweak-editor.css +4 -7
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/tweak-editor.js +3 -0
- package/modules/lifecycle/criterion-uat-db.js +267 -0
- package/modules/lifecycle/criterion-uat.js +303 -0
- package/modules/lifecycle/db-goals.js +25 -12
- package/modules/lifecycle/done-when.js +84 -17
- package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/criterion-uat.js +169 -0
- package/modules/lifecycle/routes/done-when.js +25 -1
- package/modules/npm-release/module.json +3 -1
- package/modules/npm-release/routes/task-where.js +18 -1
- package/modules/npm-release/work.js +38 -7
- package/modules/specialities/routes/specialities.js +8 -1
- package/modules/specialities/specialities.js +26 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/cli-lib.js +4 -1
- package/scripts/gds/find-untested.js +371 -0
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/newcomer-floor.templates.json +1 -1
- package/scripts/gds/status.js +14 -3
- package/scripts/gds/uat.js +120 -0
- package/src/bongos/route-rank-check.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +4 -2
- package/tests/criterion_uat.mjs +467 -0
- package/tests/criterion_uat_routes.mjs +232 -0
- package/tests/find_untested.mjs +137 -0
- package/tests/fitness.mjs +3 -1
- package/tests/goal_achievement.mjs +4 -2
- package/tests/goal_routes.mjs +4 -2
- package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
- package/tests/npm_release_where.mjs +18 -2
- package/tests/speciality_session_skills.mjs +150 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/uat.js — see, and sign off, a criterion's UAT from a session
|
|
3
|
+
// (task 1004392, goal 1000089; ADR 0351). The CLI half of /goal-uat.
|
|
4
|
+
//
|
|
5
|
+
// WHY. A criterion no longer closes because its linked tasks shipped. It reads
|
|
6
|
+
// "Awaiting UAT" until someone who did not ship the work performs it on the live
|
|
7
|
+
// site and signs it off with a recording (or, for a criterion marked
|
|
8
|
+
// backend-only, a recording-free sign-off). This is how a session does that
|
|
9
|
+
// before the hall has buttons for it (task 1004402), and what /goal-uat drives.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node scripts/gds/uat.js --queue [--version V] every criterion awaiting UAT
|
|
13
|
+
// node scripts/gds/uat.js <criterion-id> its three checks + sign-offs
|
|
14
|
+
// node scripts/gds/uat.js <criterion-id> --recording <file.mp4|.webm> [--live-attested] [--note "..."]
|
|
15
|
+
// node scripts/gds/uat.js <criterion-id> --backend-signoff [--live-attested] [--note "..."]
|
|
16
|
+
// node scripts/gds/uat.js <criterion-id> --backend-only on|off
|
|
17
|
+
//
|
|
18
|
+
// <criterion-id> is the criterion's numeric id (the `id` /status and the queue
|
|
19
|
+
// print), not its slug. --live-attested is only needed where this site cannot
|
|
20
|
+
// tell on its own whether the work is live; the server says so when it is.
|
|
21
|
+
|
|
22
|
+
const { apiCall, arg, argText, hasFlag, cliExit } = require('./cli-lib');
|
|
23
|
+
const uat = require('../../modules/lifecycle/criterion-uat');
|
|
24
|
+
|
|
25
|
+
const API = '/api/bongos';
|
|
26
|
+
|
|
27
|
+
function refusalText(data) {
|
|
28
|
+
const e = data && data.error;
|
|
29
|
+
if (!e) return 'the server refused it.';
|
|
30
|
+
const msg = typeof e === 'string' ? e : (e.message || e.code);
|
|
31
|
+
const extra = [];
|
|
32
|
+
const details = (typeof e === 'object' && e.details) || data;
|
|
33
|
+
if (details && Array.isArray(details.remaining) && details.remaining.length) {
|
|
34
|
+
extra.push(`still open: ${details.remaining.map((t) => `task ${t.id} (${t.status})`).join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
if (details && Array.isArray(details.not_live) && details.not_live.length) {
|
|
37
|
+
extra.push(`not live yet: ${details.not_live.map((t) => `task ${t.task_id}${t.stage ? ` (${t.stage})` : ''}`).join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
return [msg, ...extra].join('\n ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function showQueue() {
|
|
43
|
+
const version = arg('--version');
|
|
44
|
+
const q = version ? `?version=${encodeURIComponent(version)}` : '';
|
|
45
|
+
const r = await apiCall('GET', `${API}/done-when/pending-review${q}`);
|
|
46
|
+
if (!r.ok) { console.error(`Could not read the queue: ${refusalText(r.data)}`); return 1; }
|
|
47
|
+
const rows = (r.data.criteria || []).filter((c) => c.awaiting_uat);
|
|
48
|
+
if (!rows.length) { console.log('Nothing is awaiting UAT.'); return 0; }
|
|
49
|
+
console.log(`${rows.length} criteri${rows.length === 1 ? 'on' : 'a'} awaiting UAT:\n`);
|
|
50
|
+
for (const c of rows) {
|
|
51
|
+
const tag = c.backend_only ? ' [backend-only]' : '';
|
|
52
|
+
console.log(` criterion ${c.id} ${c.criterion_id}${tag}`);
|
|
53
|
+
console.log(` goal ${c.goal_id ?? '-'}${c.goal_title ? ` "${c.goal_title}"` : ''}${c.is_last_in_goal ? ' (last open criterion: signing it off can achieve the goal)' : ''}`);
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function showOne(id) {
|
|
59
|
+
const r = await apiCall('GET', `${API}/done-when/${id}/uat`);
|
|
60
|
+
if (!r.ok) { console.error(`Could not read criterion ${id}: ${refusalText(r.data)}`); return 1; }
|
|
61
|
+
const v = r.data;
|
|
62
|
+
console.log(`criterion ${v.criterion.id} ${v.criterion.criterion_id} — ${v.uat_label}${v.backend_only ? ' [backend-only]' : ''}\n`);
|
|
63
|
+
console.log(String(v.criterion.criterion_md || '').trim());
|
|
64
|
+
console.log('');
|
|
65
|
+
console.log(` Code ${v.checks.code ? 'passed' : 'not yet'} (${v.linked_tasks.length} linked: ${v.linked_tasks.map((t) => `${t.id} ${t.status}`).join(', ') || 'none'})`);
|
|
66
|
+
const live = v.checks.live;
|
|
67
|
+
console.log(` Live ${live == null ? '-' : !live.available ? 'this site cannot tell; the signer confirms it' : live.live ? 'running on the site' : `not yet: ${(live.not_live || []).map((t) => t.task_id).join(', ')}`}`);
|
|
68
|
+
const s = v.checks.uat;
|
|
69
|
+
console.log(` UAT ${s ? `${s.kind === 'uat' ? 'recorded' : 'backend sign-off'} by ${s.signed_off_by_login || s.signed_off_by} on ${String(s.created_at).slice(0, 10)}` : 'not yet'}`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function signOff(id, kind) {
|
|
74
|
+
let recording = null;
|
|
75
|
+
if (kind === 'uat') {
|
|
76
|
+
const file = arg('--recording');
|
|
77
|
+
const read = uat.readRecordingFile(file);
|
|
78
|
+
if (!read.ok) { console.error(`Recording refused: ${read.message}`); return 1; }
|
|
79
|
+
const up = await apiCall('POST', `${API}/done-when/${id}/uat/recording`, null, { rawBody: read.buf, contentType: read.contentType });
|
|
80
|
+
if (!up.ok) { console.error(`Upload refused: ${refusalText(up.data)}`); return 1; }
|
|
81
|
+
recording = up.data.recording;
|
|
82
|
+
console.log(`Uploaded the recording (${Math.round(up.data.bytes / 1024 / 1024 * 10) / 10} MB).`);
|
|
83
|
+
}
|
|
84
|
+
const body = { kind };
|
|
85
|
+
if (recording) body.recording = recording;
|
|
86
|
+
if (hasFlag('--live-attested')) body.live_attested = true;
|
|
87
|
+
const note = argText('--note');
|
|
88
|
+
if (note) body.note = note;
|
|
89
|
+
const r = await apiCall('POST', `${API}/done-when/${id}/uat`, body);
|
|
90
|
+
if (!r.ok) { console.error(`Sign-off refused: ${refusalText(r.data)}`); return 1; }
|
|
91
|
+
console.log(r.data.criterion_closed ? `Signed off. Criterion ${id} is now closed.` : `Signed off, but criterion ${id} did not close yet.`);
|
|
92
|
+
for (const g of r.data.achieved_goals || []) console.log(` That was the goal's last criterion: goal ${g} is achieved.`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function setBackendOnly(id, value) {
|
|
97
|
+
if (value !== 'on' && value !== 'off') { console.error('--backend-only takes on or off.'); return 1; }
|
|
98
|
+
const r = await apiCall('PUT', `${API}/done-when/${id}/backend-only`, { backend_only: value === 'on' });
|
|
99
|
+
if (!r.ok) { console.error(`Refused: ${refusalText(r.data)}`); return 1; }
|
|
100
|
+
console.log(`Criterion ${id} is ${r.data.backend_only ? 'now marked backend-only' : 'no longer backend-only'}.`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main() {
|
|
105
|
+
if (hasFlag('--queue')) return showQueue();
|
|
106
|
+
const id = process.argv[2];
|
|
107
|
+
if (!id || !/^\d{1,12}$/.test(id)) {
|
|
108
|
+
console.error('usage: node scripts/gds/uat.js <criterion-id> [--recording f | --backend-signoff | --backend-only on|off] | --queue');
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
if (arg('--backend-only')) return setBackendOnly(id, arg('--backend-only'));
|
|
112
|
+
if (hasFlag('--backend-signoff')) return signOff(id, 'backend_signoff');
|
|
113
|
+
if (arg('--recording')) return signOff(id, 'uat');
|
|
114
|
+
return showOne(id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main().then((code) => cliExit(code), (err) => {
|
|
118
|
+
console.error(`uat: ${err && err.message}`);
|
|
119
|
+
cliExit(1);
|
|
120
|
+
});
|
|
@@ -425,6 +425,15 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
425
425
|
// — but it is scope disposition, not an authority grant, so it delegates with the
|
|
426
426
|
// rest. Pinned by atom so the guarded invariant is the permission, not its floor.
|
|
427
427
|
'POST /done-when/:criterionId/unsatisfy': 'perm:criterion.unsatisfy',
|
|
428
|
+
// task 1004392 (ADR 0351): a criterion closes on a UAT sign-off. Uploading
|
|
429
|
+
// the recording and signing off ride `criterion.review`, the review queue's
|
|
430
|
+
// own atom, because the sign-off IS that review now; the wall that matters is
|
|
431
|
+
// in-handler (nobody who shipped the linked work may sign it off). Marking a
|
|
432
|
+
// criterion backend-only decides whether a recording is needed at all, so it
|
|
433
|
+
// is an AUTHORING act: `criterion.create` plus authorizeCriterionCreate.
|
|
434
|
+
'POST /done-when/:criterionId/uat/recording': 'perm:criterion.review',
|
|
435
|
+
'POST /done-when/:criterionId/uat': 'perm:criterion.review',
|
|
436
|
+
'PUT /done-when/:criterionId/backend-only': 'perm:criterion.create',
|
|
428
437
|
// gate-surface PR approval from the hall (task 1179, ADR 0058 amendment). ADR 0157
|
|
429
438
|
// SPLIT these two: the list route is `gate_approval.read` (metic — the Gate page
|
|
430
439
|
// opens to the working rank), while APPROVING still clears the gate-review hard
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.7'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -139,12 +139,14 @@ await test('EXEC: shipTask passes its transaction CLIENT, and the reconciler pas
|
|
|
139
139
|
await test('SCOPED vs UNSCOPED: the taskId parameter is what narrows the candidate set', async () => {
|
|
140
140
|
const scoped = fakeExec({ rows: [] });
|
|
141
141
|
await autoSatisfyShippedCriteria(scoped, { taskId: 42 });
|
|
142
|
-
|
|
142
|
+
// task 1004392 added a second scope ($2, the sign-off route's criterionId); the ship
|
|
143
|
+
// hook leaves it null.
|
|
144
|
+
assert.deepEqual(scoped.calls[0].params, [42, null]);
|
|
143
145
|
assert.match(scoped.calls[0].sql, /\$1::bigint IS NULL OR c\.id IN/, 'one SQL serves both shapes');
|
|
144
146
|
|
|
145
147
|
const sweep = fakeExec({ rows: [] });
|
|
146
148
|
await autoSatisfyShippedCriteria(sweep, {});
|
|
147
|
-
assert.deepEqual(sweep.calls[0].params, [null], 'unscoped passes NULL, which disables the narrowing clause');
|
|
149
|
+
assert.deepEqual(sweep.calls[0].params, [null, null], 'unscoped passes NULL, which disables the narrowing clause');
|
|
148
150
|
});
|
|
149
151
|
|
|
150
152
|
await test('NO-OP: nothing closed means no cascade query at all', async () => {
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// tests/criterion_uat.mjs — task 1004392 / ADR 0351: a criterion closes on a UAT.
|
|
2
|
+
//
|
|
3
|
+
// What these protect, in order of how badly it would hurt:
|
|
4
|
+
//
|
|
5
|
+
// 1. THE CLOSE NEEDS A CURRENT SIGN-OFF. autoSatisfyShippedCriteria (the ship
|
|
6
|
+
// hook and the reconciler sweep) must not close a criterion on shipped tasks
|
|
7
|
+
// alone any more, and a sign-off OLDER than the newest linked ship must not
|
|
8
|
+
// count. Losing either reopens the exact hole wa7-government fell through.
|
|
9
|
+
// 2. THE SIGNER WALL. Whoever shipped linked work is refused; the project owner
|
|
10
|
+
// is the one exception (a solo project must not deadlock). Asserted in the
|
|
11
|
+
// pure ladder AND through the real signOff transaction, so a refusal that
|
|
12
|
+
// exists in the ladder but is never consulted cannot pass.
|
|
13
|
+
// 3. THE STATES people read ("Awaiting UAT", "Closed before UAT"), and that an
|
|
14
|
+
// all-abandoned criterion is NOT awaiting a UAT (nothing was delivered).
|
|
15
|
+
// 4. THE RECORDING STORE: type + magic bytes + cap, and a name shape that equals
|
|
16
|
+
// the migration's CHECK.
|
|
17
|
+
//
|
|
18
|
+
// DB-free: fake executors script the rows and record the SQL. The real-SQL
|
|
19
|
+
// semantics are the integration lane's; the shapes and the branching are here.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/criterion_uat.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { makeRunner } from './helpers.mjs';
|
|
29
|
+
|
|
30
|
+
process.env.NODE_ENV = 'test';
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const uat = require('../modules/lifecycle/criterion-uat.js');
|
|
33
|
+
const uatDb = require('../modules/lifecycle/criterion-uat-db.js');
|
|
34
|
+
const doneWhen = require('../modules/lifecycle/done-when.js');
|
|
35
|
+
const { test, summary } = makeRunner();
|
|
36
|
+
|
|
37
|
+
const S = uat.UAT_STATES;
|
|
38
|
+
|
|
39
|
+
// ---- 1. the states ----------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
await test('STATE: shipped work with no sign-off reads Awaiting UAT', () => {
|
|
42
|
+
assert.equal(uat.uatState({ satisfied: false, linked: 3, unshipped: 0, shipped: 2 }), S.AWAITING_UAT);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await test('STATE: open linked work, or none linked, reads Open', () => {
|
|
46
|
+
assert.equal(uat.uatState({ satisfied: false, linked: 3, unshipped: 1, shipped: 2 }), S.OPEN);
|
|
47
|
+
assert.equal(uat.uatState({ satisfied: false, linked: 0, unshipped: 0, shipped: 0 }), S.OPEN);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await test('STATE: ALL-ABANDONED is not awaiting a UAT — nothing was delivered to test', () => {
|
|
51
|
+
// ADR 0183's delivery guard, kept as the Code check. /goal-review owns this row.
|
|
52
|
+
assert.equal(uat.uatState({ satisfied: false, linked: 2, unshipped: 0, shipped: 0 }), S.OPEN);
|
|
53
|
+
assert.equal(uat.codeMet({ linked: 2, unshipped: 0, shipped: 0 }), false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('STATE: a satisfied criterion reads by the kind of its newest sign-off', () => {
|
|
57
|
+
const sat = { satisfied: true, linked: 1, unshipped: 0, shipped: 1 };
|
|
58
|
+
assert.equal(uat.uatState({ ...sat, latestKind: 'uat' }), S.SATISFIED_UAT);
|
|
59
|
+
assert.equal(uat.uatState({ ...sat, latestKind: 'backend_signoff' }), S.SATISFIED_BACKEND);
|
|
60
|
+
assert.equal(uat.uatState({ ...sat, latestKind: 'override' }), S.SATISFIED_OVERRIDE);
|
|
61
|
+
// The owner's decision 1: already-closed criteria stay closed and say so.
|
|
62
|
+
assert.equal(uat.uatState({ ...sat, latestKind: null }), S.CLOSED_BEFORE_UAT);
|
|
63
|
+
assert.equal(uat.stateLabel(S.CLOSED_BEFORE_UAT), 'Closed before UAT');
|
|
64
|
+
assert.equal(uat.stateLabel(S.AWAITING_UAT), 'Awaiting UAT');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await test('STATE: /status rollup carries uat_state + awaiting_uat + backend_only', () => {
|
|
68
|
+
const rows = [
|
|
69
|
+
{ cnum: 1, id: 1, criterion_id: 'a', criterion_md: 'A', satisfied: false, goal_id: null, uat_kind: null, backend_only: false, task_id: 11, task_title: 't', task_status: 'shipped' },
|
|
70
|
+
{ cnum: 2, id: 2, criterion_id: 'b', criterion_md: 'B', satisfied: true, goal_id: null, uat_kind: null, backend_only: true, task_id: 12, task_title: 't', task_status: 'shipped' },
|
|
71
|
+
{ cnum: 3, id: 3, criterion_id: 'c', criterion_md: 'C', satisfied: false, goal_id: null, uat_kind: null, backend_only: false, task_id: 13, task_title: 't', task_status: 'abandoned' },
|
|
72
|
+
];
|
|
73
|
+
const out = doneWhen.buildCriterionProgress('V', rows);
|
|
74
|
+
const [a, b, c] = out.criteria;
|
|
75
|
+
assert.equal(a.uat_state, S.AWAITING_UAT);
|
|
76
|
+
assert.equal(a.awaiting_uat, true);
|
|
77
|
+
assert.equal(b.uat_state, S.CLOSED_BEFORE_UAT);
|
|
78
|
+
assert.equal(b.backend_only, true);
|
|
79
|
+
// The asymmetry survives: all-abandoned is flagged for a person, not for a UAT.
|
|
80
|
+
assert.equal(c.pending_review, true);
|
|
81
|
+
assert.equal(c.awaiting_uat, false);
|
|
82
|
+
assert.ok(!('uat_kind' in a), 'the raw sign-off kind is folded into uat_state, not leaked beside it');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---- 2. the Live fold ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
await test('LIVE: no reading, or a partial one, is unavailable (the signer attests)', () => {
|
|
88
|
+
assert.deepEqual(uat.foldLiveReadings(null), { available: false });
|
|
89
|
+
assert.deepEqual(uat.foldLiveReadings([{ task_id: '1', stage: 'released', partial: true }]), { available: false });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('LIVE: every task in a live stage is live; any other stage names the task', () => {
|
|
93
|
+
assert.deepEqual(uat.foldLiveReadings([{ task_id: '1', stage: 'released' }, { task_id: '2', stage: 'live_unreleased' }]), { available: true, live: true });
|
|
94
|
+
const r = uat.foldLiveReadings([{ task_id: '1', stage: 'released' }, { task_id: '2', stage: 'not_live' }]);
|
|
95
|
+
assert.equal(r.live, false);
|
|
96
|
+
assert.deepEqual(r.not_live, [{ task_id: '2', stage: 'not_live' }]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---- 3. the sign-off ladder (pure) -----------------------------------------
|
|
100
|
+
|
|
101
|
+
const MET = { linked: 2, unshipped: 0, shipped: 2 };
|
|
102
|
+
const base = {
|
|
103
|
+
criterion: { satisfied: false }, counts: MET, backendOnly: false, kind: 'uat',
|
|
104
|
+
signerId: 50, ownerId: 3, shipperIds: ['7', '8'], recording: 'uat-1-0123456789abcdef.mp4',
|
|
105
|
+
live: { available: true, live: true },
|
|
106
|
+
};
|
|
107
|
+
const code = (over) => { const r = uat.signoffRefusal({ ...base, ...over }); return r && r.code; };
|
|
108
|
+
|
|
109
|
+
await test('LADDER: a non-shipper with a recording on live work is allowed', () => {
|
|
110
|
+
assert.equal(uat.signoffRefusal(base), null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await test('LADDER: whoever shipped linked work is refused — ids compare as strings', () => {
|
|
114
|
+
assert.equal(code({ signerId: 7 }), 'signer_shipped_this_work');
|
|
115
|
+
assert.equal(code({ signerId: '8' }), 'signer_shipped_this_work');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('LADDER: the project owner may sign off work they shipped (solo projects)', () => {
|
|
119
|
+
assert.equal(code({ signerId: 3, shipperIds: ['3'] }), null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('LADDER: nothing to sign — closed, open work, or nothing delivered', () => {
|
|
123
|
+
assert.equal(code({ criterion: { satisfied: true } }), 'criterion_already_satisfied');
|
|
124
|
+
assert.equal(code({ counts: { linked: 2, unshipped: 1, shipped: 1 } }), 'criterion_not_awaiting_uat');
|
|
125
|
+
assert.equal(code({ counts: { linked: 2, unshipped: 0, shipped: 0 } }), 'criterion_not_awaiting_uat');
|
|
126
|
+
assert.equal(code({ criterion: null }), 'criterion_not_found');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await test('LADDER: the kind must match the criterion', () => {
|
|
130
|
+
assert.equal(code({ backendOnly: true }), 'criterion_is_backend_only');
|
|
131
|
+
assert.equal(code({ kind: 'backend_signoff', recording: null }), 'criterion_needs_recording');
|
|
132
|
+
assert.equal(code({ kind: 'backend_signoff', recording: null, backendOnly: true }), null);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('LADDER: a UAT needs its recording', () => {
|
|
136
|
+
assert.equal(code({ recording: null }), 'uat_recording_required');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await test('LADDER: not live is refused where readable; attestation is required where not', () => {
|
|
140
|
+
assert.equal(code({ live: { available: true, live: false, not_live: [] } }), 'work_not_live');
|
|
141
|
+
assert.equal(code({ live: { available: false } }), 'live_attestation_required');
|
|
142
|
+
assert.equal(code({ live: { available: false }, liveAttested: true }), null);
|
|
143
|
+
assert.equal(uat.liveBasis({ available: true, live: true }), 'deploy_reading');
|
|
144
|
+
assert.equal(uat.liveBasis({ available: false }), 'attested');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('LADDER: the shipper refusal outranks missing evidence (they are told the real reason)', () => {
|
|
148
|
+
assert.equal(code({ signerId: 7, recording: null, live: { available: false } }), 'signer_shipped_this_work');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---- 4. the close path ------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function fakeExec(...results) {
|
|
154
|
+
const calls = [];
|
|
155
|
+
return { calls, async query(sql, params) { calls.push({ sql, params }); return results[calls.length - 1] || { rows: [] }; } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await test('CLOSE (sql): the auto-close requires a CURRENT uat/backend sign-off row', async () => {
|
|
159
|
+
const exec = fakeExec({ rows: [] });
|
|
160
|
+
await doneWhen.autoSatisfyShippedCriteria(exec, {});
|
|
161
|
+
const sql = exec.calls[0].sql;
|
|
162
|
+
assert.match(sql, /EXISTS \(SELECT 1 FROM lifecycle_criterion_uats u/, 'a sign-off row must exist');
|
|
163
|
+
assert.match(sql, /u\.kind IN \('uat', 'backend_signoff'\)/, 'an override row is not a candidate — the override route satisfies directly');
|
|
164
|
+
assert.match(sql, /u\.created_at >= COALESCE\(candidate\.last_ship/, 'a sign-off older than the newest linked ship is stale');
|
|
165
|
+
assert.match(sql, /max\(t\.shipped_at\) FILTER \(WHERE t\.status = 'shipped'\) AS last_ship/);
|
|
166
|
+
// The delivery guard (ADR 0183) is still there.
|
|
167
|
+
assert.match(sql, /count\(\*\) FILTER \(WHERE t\.status = 'shipped'\) >= 1/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('CLOSE: the criterionId scope binds $2 and leaves the task scope null', async () => {
|
|
171
|
+
const exec = fakeExec({ rows: [] });
|
|
172
|
+
await doneWhen.autoSatisfyShippedCriteria(exec, { criterionId: 42 });
|
|
173
|
+
assert.deepEqual(exec.calls[0].params, [null, 42]);
|
|
174
|
+
assert.match(exec.calls[0].sql, /\$2::bigint IS NULL OR c\.id = \$2::bigint/);
|
|
175
|
+
const shipScoped = fakeExec({ rows: [] });
|
|
176
|
+
await doneWhen.autoSatisfyShippedCriteria(shipScoped, { taskId: 9 });
|
|
177
|
+
assert.deepEqual(shipScoped.calls[0].params, [9, null], 'the ship hook still scopes by task');
|
|
178
|
+
await assert.rejects(() => doneWhen.autoSatisfyShippedCriteria(fakeExec(), { criterionId: 'x' }), /criterionId must be numeric/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---- 5. signOff, through its real transaction ------------------------------
|
|
182
|
+
|
|
183
|
+
// A SQL-routed fake: the same object is the pool and the transaction client.
|
|
184
|
+
function signoffDb({ linked, linkedAfter = null, satisfied = false, backendOnly = false, closes = true }) {
|
|
185
|
+
const calls = [];
|
|
186
|
+
let linkedReads = 0;
|
|
187
|
+
const db = {
|
|
188
|
+
calls,
|
|
189
|
+
async query(sql, params) {
|
|
190
|
+
calls.push({ sql, params });
|
|
191
|
+
if (/FROM task_criteria tc/.test(sql) && /shipped_by/.test(sql)) {
|
|
192
|
+
linkedReads += 1;
|
|
193
|
+
return { rows: linkedReads > 1 && linkedAfter ? linkedAfter : linked };
|
|
194
|
+
}
|
|
195
|
+
if (/FROM done_when_criteria WHERE id = \$1 FOR UPDATE/.test(sql)) return { rows: [{ id: params[0], satisfied, goal_id: null, version_id: 'V' }] };
|
|
196
|
+
if (/lifecycle_criterion_backend_only/.test(sql)) return { rows: backendOnly ? [{ '?column?': 1 }] : [] };
|
|
197
|
+
if (/INSERT INTO lifecycle_criterion_uats/.test(sql)) return { rows: [{ id: 1, kind: params[1], live_basis: params[4] }] };
|
|
198
|
+
if (/WITH candidate AS/.test(sql)) return { rows: closes ? [{ id: params[1], goal_id: null }] : [] };
|
|
199
|
+
return { rows: [] };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
return db;
|
|
203
|
+
}
|
|
204
|
+
const withTx = (db) => async (fn) => fn(db);
|
|
205
|
+
const SHIPPED = [
|
|
206
|
+
{ id: 11, status: 'shipped', shipped_by: 7, shipped_at: new Date() },
|
|
207
|
+
{ id: 12, status: 'shipped', shipped_by: 8, shipped_at: new Date() },
|
|
208
|
+
];
|
|
209
|
+
const REC = 'uat-5-0123456789abcdef.webm';
|
|
210
|
+
const inserts = (db) => db.calls.filter((c) => /INSERT INTO lifecycle_criterion_uats/.test(c.sql));
|
|
211
|
+
|
|
212
|
+
await test('SIGNOFF: a non-shipper signs, the row is written, and the criterion closes in the same tx', async () => {
|
|
213
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
214
|
+
const out = await uatDb.signOff(
|
|
215
|
+
{ criterionId: 5, kind: 'uat', signerId: 50, recording: REC, liveAttested: true, note: ' checked the page ' },
|
|
216
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3 },
|
|
217
|
+
);
|
|
218
|
+
assert.equal(out.ok, true, JSON.stringify(out.refusal));
|
|
219
|
+
const ins = inserts(db);
|
|
220
|
+
assert.equal(ins.length, 1);
|
|
221
|
+
assert.deepEqual(ins[0].params, [5, 'uat', 50, REC, 'attested', 'checked the page']);
|
|
222
|
+
const close = db.calls.find((c) => /WITH candidate AS/.test(c.sql));
|
|
223
|
+
assert.ok(close, 'the sign-off must run the close');
|
|
224
|
+
assert.deepEqual(close.params, [null, 5], 'scoped to THIS criterion');
|
|
225
|
+
assert.ok(db.calls.indexOf(close) > db.calls.indexOf(ins[0]), 'the close runs after the row it depends on');
|
|
226
|
+
assert.equal(out.closed.criteria.length, 1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('SIGNOFF: a shipper is refused and NOTHING is written', async () => {
|
|
230
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
231
|
+
const out = await uatDb.signOff(
|
|
232
|
+
{ criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
|
|
233
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3 },
|
|
234
|
+
);
|
|
235
|
+
assert.equal(out.ok, false);
|
|
236
|
+
assert.equal(out.refusal.code, 'signer_shipped_this_work');
|
|
237
|
+
assert.equal(inserts(db).length, 0);
|
|
238
|
+
assert.ok(!db.calls.some((c) => /WITH candidate AS/.test(c.sql)), 'a refusal never runs the close');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await test('SIGNOFF: the project owner may sign off their own shipped work', async () => {
|
|
242
|
+
const db = signoffDb({ linked: [{ id: 11, status: 'shipped', shipped_by: 3, shipped_at: new Date() }] });
|
|
243
|
+
const out = await uatDb.signOff(
|
|
244
|
+
{ criterionId: 5, kind: 'uat', signerId: 3, recording: REC, liveAttested: true },
|
|
245
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3 },
|
|
246
|
+
);
|
|
247
|
+
assert.equal(out.ok, true, JSON.stringify(out.refusal));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await test('SIGNOFF: the deploy reading refuses work that is not live, and names it', async () => {
|
|
251
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
252
|
+
const deployPort = { readTaskWhere: async (id) => ({ stage: id === '12' ? 'not_live' : 'released', partial: false }) };
|
|
253
|
+
const out = await uatDb.signOff(
|
|
254
|
+
{ criterionId: 5, kind: 'uat', signerId: 50, recording: REC },
|
|
255
|
+
{ pool: db, withTx: withTx(db), deployPort, ownerId: 3 },
|
|
256
|
+
);
|
|
257
|
+
assert.equal(out.refusal.code, 'work_not_live');
|
|
258
|
+
assert.deepEqual(out.refusal.not_live, [{ task_id: '12', stage: 'not_live' }]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await test('SIGNOFF: a live reading is recorded as deploy_reading, and a THROWING reader falls back to attestation', async () => {
|
|
262
|
+
const live = signoffDb({ linked: SHIPPED });
|
|
263
|
+
const ok = await uatDb.signOff(
|
|
264
|
+
{ criterionId: 5, kind: 'uat', signerId: 50, recording: REC },
|
|
265
|
+
{ pool: live, withTx: withTx(live), deployPort: { readTaskWhere: async () => ({ stage: 'released' }) }, ownerId: 3 },
|
|
266
|
+
);
|
|
267
|
+
assert.equal(ok.ok, true);
|
|
268
|
+
assert.equal(inserts(live)[0].params[4], 'deploy_reading');
|
|
269
|
+
const broken = signoffDb({ linked: SHIPPED });
|
|
270
|
+
const out = await uatDb.signOff(
|
|
271
|
+
{ criterionId: 5, kind: 'uat', signerId: 50, recording: REC },
|
|
272
|
+
{ pool: broken, withTx: withTx(broken), deployPort: { readTaskWhere: async () => { throw new Error('registry down'); } }, ownerId: 3 },
|
|
273
|
+
);
|
|
274
|
+
assert.equal(out.refusal.code, 'live_attestation_required', 'an outage moves Live to the signer, never blocks them outright');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await test('SIGNOFF: linked work that changed between the reading and the lock is refused', async () => {
|
|
278
|
+
const db = signoffDb({ linked: SHIPPED, linkedAfter: [...SHIPPED, { id: 13, status: 'shipped', shipped_by: 9, shipped_at: new Date() }] });
|
|
279
|
+
const out = await uatDb.signOff(
|
|
280
|
+
{ criterionId: 5, kind: 'uat', signerId: 50, recording: REC, liveAttested: true },
|
|
281
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3 },
|
|
282
|
+
);
|
|
283
|
+
assert.equal(out.refusal.code, 'linked_work_changed');
|
|
284
|
+
assert.equal(inserts(db).length, 0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test('SIGNOFF: a recording uploaded for another criterion is refused before any read', async () => {
|
|
288
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
289
|
+
const out = await uatDb.signOff(
|
|
290
|
+
{ criterionId: 6, kind: 'uat', signerId: 50, recording: REC, liveAttested: true },
|
|
291
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3 },
|
|
292
|
+
);
|
|
293
|
+
assert.equal(out.refusal.code, 'recording_not_for_this_criterion');
|
|
294
|
+
assert.equal(db.calls.length, 0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await test('SIGNOFF: an override cannot be submitted through the sign-off path', async () => {
|
|
298
|
+
const out = await uatDb.signOff({ criterionId: 5, kind: 'override', signerId: 50 }, {});
|
|
299
|
+
assert.equal(out.refusal.code, 'bad_kind');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---- 6. the recording store -------------------------------------------------
|
|
303
|
+
|
|
304
|
+
const mp4 = (n = 64) => { const b = Buffer.alloc(n); b.write('ftyp', 4, 'latin1'); return b; };
|
|
305
|
+
const webm = (n = 64) => { const b = Buffer.alloc(n); b[0] = 0x1a; b[1] = 0x45; b[2] = 0xdf; b[3] = 0xa3; return b; };
|
|
306
|
+
|
|
307
|
+
await test('STORE: mp4 and webm pass; a mislabelled or foreign file does not', () => {
|
|
308
|
+
assert.equal(uat.screenRecording(mp4(), 'video/mp4').ok, true);
|
|
309
|
+
assert.equal(uat.screenRecording(webm(), 'video/webm; codecs=vp9').ok, true);
|
|
310
|
+
assert.equal(uat.screenRecording(webm(), 'video/mp4').reason, 'sniff_mismatch');
|
|
311
|
+
assert.equal(uat.screenRecording(mp4(), 'image/png').reason, 'bad_type');
|
|
312
|
+
assert.equal(uat.screenRecording(Buffer.alloc(0), 'video/mp4').reason, 'empty');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await test('STORE: the 100 MB cap is exact', () => {
|
|
316
|
+
assert.equal(uat.MAX_RECORDING_BYTES, 100 * 1024 * 1024);
|
|
317
|
+
assert.equal(uat.screenRecording(mp4(uat.MAX_RECORDING_BYTES), 'video/mp4').ok, true);
|
|
318
|
+
assert.equal(uat.screenRecording(mp4(uat.MAX_RECORDING_BYTES + 1), 'video/mp4').reason, 'too_big');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await test('STORE: a stored name is server-minted, carries its criterion, and resolves only inside the dir', async () => {
|
|
322
|
+
const dir = mkdtempSync(join(tmpdir(), 'uat-store-'));
|
|
323
|
+
try {
|
|
324
|
+
const r = await uat.persistRecording(mp4(), { criterionId: 77, contentType: 'video/mp4', dir });
|
|
325
|
+
assert.equal(r.ok, true);
|
|
326
|
+
assert.match(r.file, /^uat-77-[0-9a-f]{16}\.mp4$/);
|
|
327
|
+
assert.equal(uat.criterionIdFromName(r.file), 77);
|
|
328
|
+
assert.ok(existsSync(uat.resolveSafe(r.file, { dir })));
|
|
329
|
+
for (const bad of ['../uat-1-0123456789abcdef.mp4', 'uat-1-0123456789abcdef.mp4/..', 'uat-1-xyz.mp4', 'task-1-0123456789abcdef.png']) {
|
|
330
|
+
assert.equal(uat.resolveSafe(bad, { dir }), null, bad);
|
|
331
|
+
}
|
|
332
|
+
} finally {
|
|
333
|
+
rmSync(dir, { recursive: true, force: true });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await test('STORE: the app and the migration CHECK accept and refuse the same names', () => {
|
|
338
|
+
const sql = readFileSync(new URL('../modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql', import.meta.url), 'utf8');
|
|
339
|
+
// This pattern uses only syntax POSIX and JS regexes read identically.
|
|
340
|
+
const check = new RegExp(/recording_file ~ '([^']+)'/.exec(sql)[1]);
|
|
341
|
+
const names = [
|
|
342
|
+
'uat-1-0123456789abcdef.mp4', 'uat-123456789012-fedcba9876543210.webm',
|
|
343
|
+
'uat-1234567890123-0123456789abcdef.mp4', 'uat-1-0123456789ABCDEF.mp4', 'uat-1-0123456789abcdef.mov',
|
|
344
|
+
'uat--0123456789abcdef.mp4', 'uat-1-0123456789abcde.mp4', 'x/uat-1-0123456789abcdef.mp4', 'uat-1-0123456789abcdef.mp4x',
|
|
345
|
+
];
|
|
346
|
+
for (const n of names) assert.equal(uat.isSafeName(n), check.test(n), `app and DB disagree on ${n}`);
|
|
347
|
+
assert.ok(names.some((n) => uat.isSafeName(n)) && names.some((n) => !uat.isSafeName(n)), 'the probe set must hold both answers');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ---- 7. the read behind GET /done-when/:id/uat ------------------------------
|
|
351
|
+
|
|
352
|
+
// A SQL-routed fake for uatView: the criterion row, its linked tasks, its
|
|
353
|
+
// sign-offs (newest first, as listSignoffs orders them), and the backend flag.
|
|
354
|
+
function viewDb({ satisfied = false, linked, signoffs = [], backendOnly = false }) {
|
|
355
|
+
return {
|
|
356
|
+
async query(sql) {
|
|
357
|
+
if (/FROM done_when_criteria WHERE id = \$1/.test(sql)) return { rows: [{ id: 5, criterion_id: 'c', criterion_md: 'md', satisfied, goal_id: 3, version_id: 'V' }] };
|
|
358
|
+
if (/FROM task_criteria tc/.test(sql)) return { rows: linked };
|
|
359
|
+
if (/FROM lifecycle_criterion_uats u/.test(sql)) return { rows: signoffs };
|
|
360
|
+
if (/lifecycle_criterion_backend_only/.test(sql)) return { rows: backendOnly ? [{ x: 1 }] : [] };
|
|
361
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const T0 = new Date('2026-09-20T10:00:00Z');
|
|
366
|
+
const T1 = new Date('2026-09-25T10:00:00Z');
|
|
367
|
+
|
|
368
|
+
await test('VIEW: a sign-off older than the newest linked ship is reported STALE, never as the UAT check', async () => {
|
|
369
|
+
const linked = [{ id: 11, status: 'shipped', shipped_by: 7, shipped_at: T0 }, { id: 12, status: 'shipped', shipped_by: 8, shipped_at: T1 }];
|
|
370
|
+
const stale = { id: 1, kind: 'uat', signed_off_by: 50, created_at: new Date('2026-09-22T10:00:00Z') };
|
|
371
|
+
const v = await uatDb.uatView(5, { pool: viewDb({ linked, signoffs: [stale] }), deployPort: null });
|
|
372
|
+
assert.equal(v.uat_state, S.AWAITING_UAT);
|
|
373
|
+
assert.equal(v.checks.code, true);
|
|
374
|
+
assert.equal(v.checks.uat, null, 'work shipped after the sign-off, so it does not count');
|
|
375
|
+
assert.equal(v.checks.stale_uat.id, 1, 'and the reader is told why');
|
|
376
|
+
assert.deepEqual(v.checks.live, { available: false }, 'no deploy reading here: the signer will attest');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await test('VIEW: a current sign-off is the UAT check, and an override row never is', async () => {
|
|
380
|
+
const linked = [{ id: 11, status: 'shipped', shipped_by: 7, shipped_at: T0 }];
|
|
381
|
+
const override = { id: 3, kind: 'override', created_at: T1 };
|
|
382
|
+
const current = { id: 2, kind: 'backend_signoff', created_at: T1 };
|
|
383
|
+
const v = await uatDb.uatView(5, { pool: viewDb({ satisfied: true, linked, signoffs: [override, current], backendOnly: true }) });
|
|
384
|
+
assert.equal(v.checks.uat.id, 2);
|
|
385
|
+
assert.equal(v.checks.stale_uat, null);
|
|
386
|
+
assert.equal(v.backend_only, true);
|
|
387
|
+
assert.equal(v.uat_state, S.SATISFIED_OVERRIDE, 'the state reads by the NEWEST row, which is the override');
|
|
388
|
+
assert.equal(v.checks.live, null, 'Live is only read while a criterion is awaiting UAT');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await test('VIEW: Live is read from the deploy port while awaiting, and an unknown criterion is null', async () => {
|
|
392
|
+
const linked = [{ id: 11, status: 'shipped', shipped_by: 7, shipped_at: T0 }];
|
|
393
|
+
const port = { readTasksWhere: async (ids) => Object.fromEntries(ids.map((id) => [id, { stage: 'released' }])) };
|
|
394
|
+
const v = await uatDb.uatView(5, { pool: viewDb({ linked }), deployPort: port });
|
|
395
|
+
assert.deepEqual(v.checks.live, { available: true, live: true });
|
|
396
|
+
const none = { async query() { return { rows: [] }; } };
|
|
397
|
+
assert.equal(await uatDb.uatView(6, { pool: none }), null);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ---- 8. readLive: one batch read, and every failure is a PARTIAL -------------
|
|
401
|
+
|
|
402
|
+
await test('LIVE READ: the batch port is asked ONCE for every task, and a missing task is partial', async () => {
|
|
403
|
+
const calls = [];
|
|
404
|
+
const port = {
|
|
405
|
+
readTasksWhere: async (ids) => { calls.push(ids); return { 11: { stage: 'released' } }; },
|
|
406
|
+
readTaskWhere: async () => { throw new Error('the per-task form must not be used when the batch exists'); },
|
|
407
|
+
};
|
|
408
|
+
const r = await uatDb.readLive(['11', '12'], { deployPort: port });
|
|
409
|
+
assert.equal(calls.length, 1);
|
|
410
|
+
assert.deepEqual(r, [{ task_id: '11', stage: 'released', partial: false }, { task_id: '12', stage: null, partial: true }]);
|
|
411
|
+
const thrown = await uatDb.readLive(['11'], { deployPort: { readTasksWhere: async () => { throw new Error('down'); } } });
|
|
412
|
+
assert.deepEqual(thrown, [{ task_id: '11', stage: null, partial: true }]);
|
|
413
|
+
assert.equal(await uatDb.readLive(['11'], { deployPort: null }), null, 'no provider, no reading');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---- 9. backend-only: set while OPEN, never once the criterion is awaiting ---
|
|
417
|
+
|
|
418
|
+
function flagDb({ satisfied = false, linked }) {
|
|
419
|
+
const calls = [];
|
|
420
|
+
return {
|
|
421
|
+
calls,
|
|
422
|
+
async query(sql, params) {
|
|
423
|
+
calls.push({ sql, params });
|
|
424
|
+
if (/FROM done_when_criteria WHERE id = \$1 FOR UPDATE/.test(sql)) return { rows: [{ id: params[0], satisfied, goal_id: 3, version_id: 'V' }] };
|
|
425
|
+
if (/FROM task_criteria tc/.test(sql)) return { rows: linked };
|
|
426
|
+
return { rows: [] };
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const flagWrites = (db) => db.calls.filter((c) => /lifecycle_criterion_backend_only/.test(c.sql));
|
|
431
|
+
const allow = async () => null;
|
|
432
|
+
|
|
433
|
+
await test('BACKEND-ONLY: an open criterion can be marked and unmarked', async () => {
|
|
434
|
+
const open = [{ id: 11, status: 'active', shipped_by: null, shipped_at: null }];
|
|
435
|
+
const on = flagDb({ linked: open });
|
|
436
|
+
const r = await uatDb.setBackendOnly({ criterionId: 5, builderId: 50, backendOnly: true, authorize: allow }, { pool: on, withTx: withTx(on) });
|
|
437
|
+
assert.deepEqual(r, { ok: true, backend_only: true });
|
|
438
|
+
assert.match(flagWrites(on)[0].sql, /INSERT INTO lifecycle_criterion_backend_only/);
|
|
439
|
+
assert.deepEqual(flagWrites(on)[0].params, [5, 50]);
|
|
440
|
+
const off = flagDb({ linked: open });
|
|
441
|
+
await uatDb.setBackendOnly({ criterionId: 5, builderId: 50, backendOnly: false, authorize: allow }, { pool: off, withTx: withTx(off) });
|
|
442
|
+
assert.match(flagWrites(off)[0].sql, /DELETE FROM lifecycle_criterion_backend_only/);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await test('BACKEND-ONLY: LOCKED once the criterion is awaiting UAT or closed — the closer cannot skip the recording', async () => {
|
|
446
|
+
const shipped = [{ id: 11, status: 'shipped', shipped_by: 7, shipped_at: T0 }];
|
|
447
|
+
for (const [label, db] of [['awaiting', flagDb({ linked: shipped })], ['closed', flagDb({ satisfied: true, linked: shipped })]]) {
|
|
448
|
+
const r = await uatDb.setBackendOnly({ criterionId: 5, builderId: 50, backendOnly: true, authorize: allow }, { pool: db, withTx: withTx(db) });
|
|
449
|
+
assert.equal(r.refusal.code, 'backend_only_locked', label);
|
|
450
|
+
assert.equal(flagWrites(db).length, 0, `${label}: nothing written`);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await test('BACKEND-ONLY: the authority gate runs INSIDE the transaction, before any read of the work, and a refusal writes nothing', async () => {
|
|
455
|
+
const db = flagDb({ linked: [] });
|
|
456
|
+
const seen = [];
|
|
457
|
+
const r = await uatDb.setBackendOnly({
|
|
458
|
+
criterionId: 5, builderId: 50, backendOnly: true,
|
|
459
|
+
authorize: async (client, crit) => { seen.push([client === db, crit.goal_id]); return { code: 'goal_not_yours', status: 403, message: 'no' }; },
|
|
460
|
+
}, { pool: db, withTx: withTx(db) });
|
|
461
|
+
assert.equal(r.refusal.code, 'goal_not_yours');
|
|
462
|
+
assert.deepEqual(seen, [[true, 3]], 'the gate saw the locked row on the transaction client');
|
|
463
|
+
assert.equal(flagWrites(db).length, 0);
|
|
464
|
+
assert.ok(!db.calls.some((c) => /FROM task_criteria/.test(c.sql)), 'a refused caller learns nothing about the linked work');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
summary();
|