@bongos/core 1.20.65 → 1.20.67
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 +55 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +110 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +156 -154
- package/docs/copy-registry.json +196 -178
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +28 -28
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/goals-lib.js +37 -5
- package/modules/hall-ui/public/goals-render.js +14 -5
- package/modules/hall-ui/public/task.js +25 -2
- package/modules/lifecycle/goal-move.js +182 -0
- package/modules/lifecycle/goal-rollup.js +13 -7
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/goal-move.js +92 -0
- package/modules/lifecycle/task-status.js +4 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/goal.js +40 -2
- package/scripts/hall-preview/refresh-fixtures.js +5 -1
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/claim_from_task_and_home.mjs +22 -1
- package/tests/dep_blocked_ready_cards.mjs +70 -0
- package/tests/goal_move.mjs +285 -0
- package/tests/goal_sort_staleness.mjs +8 -3
- package/tests/goals_lib.mjs +58 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/goal-move.js — POST /goals/:id/move (task 1004485).
|
|
4
|
+
//
|
|
5
|
+
// Moves an open goal — its criteria and every task in it — to another version.
|
|
6
|
+
// The rules and the write live in ../goal-move.js; this file is the doorway and
|
|
7
|
+
// must never grow a query (the rot.js precedent).
|
|
8
|
+
//
|
|
9
|
+
// GATE: `version.create` (Archon floor). Re-homing a goal changes the scope of
|
|
10
|
+
// TWO versions at once, which is the same authority as authoring a version's
|
|
11
|
+
// scope in the first place — so it reuses that atom rather than minting a new
|
|
12
|
+
// one, and adds no new authority to the system. Pinned in route-rank-check.
|
|
13
|
+
//
|
|
14
|
+
// Body: { version_id, admit_reason? } — `admit_reason` is the Archon override
|
|
15
|
+
// that lets a goal into a version that is already building (the POST /goals R07
|
|
16
|
+
// admission); on a planning target it is accepted and changes nothing.
|
|
17
|
+
//
|
|
18
|
+
// MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
|
|
19
|
+
|
|
20
|
+
const express = require('express');
|
|
21
|
+
const api = require('../../../src/module-api');
|
|
22
|
+
const db = require('../db');
|
|
23
|
+
const goalMove = require('../goal-move');
|
|
24
|
+
const { validateOrRespond, parseId } = api;
|
|
25
|
+
|
|
26
|
+
const log = api.logger('lifecycle');
|
|
27
|
+
|
|
28
|
+
module.exports = function buildGoalMoveRouter() {
|
|
29
|
+
const router = express.Router();
|
|
30
|
+
|
|
31
|
+
// rank: archon (perm:version.create) — re-homes a goal and its tasks across versions.
|
|
32
|
+
router.post('/goals/:id/move', api.requireBuilder, api.requirePermission('version.create'), async (req, res) => {
|
|
33
|
+
if (validateOrRespond(req, res, {
|
|
34
|
+
version_id: { required: true, type: 'string', minLength: 1, maxLength: 64 },
|
|
35
|
+
admit_reason: { type: 'string', maxLength: 2000 },
|
|
36
|
+
})) return;
|
|
37
|
+
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
38
|
+
if (id === null) return;
|
|
39
|
+
const admitReason = (req.body.admit_reason || '').trim() || null;
|
|
40
|
+
try {
|
|
41
|
+
const goal = await db.getGoal(id);
|
|
42
|
+
const [source, target] = goal
|
|
43
|
+
? await Promise.all([db.getVersion(goal.version_id), db.getVersion(req.body.version_id)])
|
|
44
|
+
: [null, null];
|
|
45
|
+
const decision = goalMove.authorizeGoalMove({ goal, source, target, admitReason });
|
|
46
|
+
if (!decision.ok) return res.status(decision.status).json(decision.body);
|
|
47
|
+
|
|
48
|
+
const moved = await goalMove.moveGoalToVersion({
|
|
49
|
+
goalId: id,
|
|
50
|
+
fromVersionId: goal.version_id,
|
|
51
|
+
toVersionId: target.id,
|
|
52
|
+
targetStatus: target.status,
|
|
53
|
+
admission: decision.overrideApplied ? { admittedBy: req.builder.id, reason: admitReason } : null,
|
|
54
|
+
});
|
|
55
|
+
const updated = await db.getGoal(id);
|
|
56
|
+
return res.json({
|
|
57
|
+
ok: true,
|
|
58
|
+
goal: updated,
|
|
59
|
+
from_version_id: moved.fromVersionId,
|
|
60
|
+
to_version_id: moved.toVersionId,
|
|
61
|
+
moved_task_ids: moved.taskIds,
|
|
62
|
+
moved_criterion_ids: moved.criterionIds,
|
|
63
|
+
admitted: decision.overrideApplied,
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err && err.code === 'CRITERION_LINKS_CROSS_GOAL') {
|
|
67
|
+
return res.fail('criterion_links_cross_goal', {
|
|
68
|
+
status: 409,
|
|
69
|
+
message: `Goal #${id} has criterion links that cross its edge — after the move they would join two versions. Unlink them (DELETE /tasks/:id/criteria/:criterionId) or move the task into the goal first. Nothing was changed.`,
|
|
70
|
+
details: { links: err.links },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (err && err.code === 'CRITERION_ID_COLLISION') {
|
|
74
|
+
return res.fail('criterion_id_collision', {
|
|
75
|
+
status: 409,
|
|
76
|
+
message: `A criterion on goal #${id} uses a criterion_id the target version already has. Rename one of them first. Nothing was changed.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (err && (err.code === 'GOAL_CHANGED' || err.code === 'TARGET_CHANGED')) {
|
|
80
|
+
return res.fail('changed_during_move', {
|
|
81
|
+
status: 409,
|
|
82
|
+
message: 'The goal or the target version changed while the move was running — nothing was changed. Re-read and retry.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (err && err.code === 'GOAL_NOT_FOUND') return res.fail('goal_not_found', 404);
|
|
86
|
+
log.error(`POST /goals/:id/move: ${err && err.message}`);
|
|
87
|
+
return res.fail('move_failed', { status: 500, message: 'internal error' });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return router;
|
|
92
|
+
};
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
// tests/goal_sort_staleness.mjs pins). "Done" there means "no longer in the
|
|
28
28
|
// queue", which deliberately includes the mid-ship states. It is a progress
|
|
29
29
|
// ring, not a ledger, and must never be used to decide whether a goal may
|
|
30
|
-
// close.
|
|
30
|
+
// close. Note too that the bucketing is NOT a pure function of the status
|
|
31
|
+
// column: `blocked` there means "has an unshipped dependency", which a
|
|
32
|
+
// `ready` row can be (ADR 0015 / task 1004487), so neither set below is
|
|
33
|
+
// enough on its own to bucket a task.
|
|
31
34
|
//
|
|
32
35
|
// NO DEPENDENCIES, deliberately. This file requires nothing — not even
|
|
33
36
|
// `module-api` — so a CLI (`scripts/gds/sequence.js`) can read the vocabulary
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.67",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.67",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.67",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8519,5 +8519,17 @@
|
|
|
8519
8519
|
"id": "1004486",
|
|
8520
8520
|
"text": "Previewing an older version from the deploy page now works on the real server: the preview could not log in to its copy of the database, so every page in it failed. It also no longer leaves a full copy of the live database lyi"
|
|
8521
8521
|
}
|
|
8522
|
+
],
|
|
8523
|
+
"1.20.66": [
|
|
8524
|
+
{
|
|
8525
|
+
"id": "1004485",
|
|
8526
|
+
"text": "Goals can now be moved from one version to another, taking their tasks and done-when criteria with them and keeping every task number. Used first to move the two goals that did not make V2 into V3."
|
|
8527
|
+
}
|
|
8528
|
+
],
|
|
8529
|
+
"1.20.67": [
|
|
8530
|
+
{
|
|
8531
|
+
"id": "1004487",
|
|
8532
|
+
"text": "The hall no longer offers a Claim button on work the system will refuse. If a task gets a new prerequisite after it was opened up for claiming, its page and its goal card now say it is waiting on that other task — and name whi"
|
|
8533
|
+
}
|
|
8522
8534
|
]
|
|
8523
8535
|
}
|
package/scripts/gds/goal.js
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
// Usage:
|
|
5
5
|
// node scripts/gds/goal.js list [--version V] [--status open|achieved|archived|all]
|
|
6
6
|
// node scripts/gds/goal.js show <id>
|
|
7
|
+
// node scripts/gds/goal.js move <id> --to <version> [--admit-reason "..."]
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
+
// list/show are read-only; default list filter is --status open. `move` re-homes an
|
|
10
|
+
// open goal — its criteria and every task in it — on another version (Archon;
|
|
11
|
+
// POST /goals/:id/move, task 1004485).
|
|
9
12
|
|
|
10
13
|
const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
11
14
|
|
|
@@ -16,6 +19,7 @@ const LIST_STATUSES = ['open', 'achieved', 'archived', 'all'];
|
|
|
16
19
|
function usageExit() {
|
|
17
20
|
console.error('usage: node scripts/gds/goal.js list [--version V] [--status open|achieved|archived|all]');
|
|
18
21
|
console.error(' node scripts/gds/goal.js show <id>');
|
|
22
|
+
console.error(' node scripts/gds/goal.js move <id> --to <version> [--admit-reason "..."]');
|
|
19
23
|
process.exit(2);
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -208,6 +212,38 @@ async function printTaskBuckets(api, tasks) {
|
|
|
208
212
|
}
|
|
209
213
|
}
|
|
210
214
|
|
|
215
|
+
// parseMoveArgs — the argv after `move`: `<id> --to <version> [--admit-reason "..."]`.
|
|
216
|
+
// Returns { id, body } or null (usage error). Pure, so the CLI's own validation
|
|
217
|
+
// is tested rather than trusted (task 1004485).
|
|
218
|
+
function parseMoveArgs(args) {
|
|
219
|
+
const id = args[0];
|
|
220
|
+
if (!/^\d+$/.test(id || '')) return null;
|
|
221
|
+
const rest = args.slice(1);
|
|
222
|
+
const to = arg('--to', rest);
|
|
223
|
+
if (!to || !/^[\w.-]+$/.test(to)) return null;
|
|
224
|
+
const body = { version_id: to };
|
|
225
|
+
const reason = arg('--admit-reason', rest);
|
|
226
|
+
if (reason) body.admit_reason = reason;
|
|
227
|
+
return { id: Number(id), body };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function move(api, args) {
|
|
231
|
+
const parsed = parseMoveArgs(args);
|
|
232
|
+
if (!parsed) usageExit();
|
|
233
|
+
const { id, body } = parsed;
|
|
234
|
+
const r = await api.goals.postGoalsIdMove({ id, body });
|
|
235
|
+
if (!r.ok) {
|
|
236
|
+
console.error(`move refused (${r.status}):`, (r.data && (r.data.message || r.data.error)) || JSON.stringify(r.data));
|
|
237
|
+
if (r.data && r.data.details && r.data.details.links) console.error(' crossing links:', JSON.stringify(r.data.details.links));
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const d = r.data;
|
|
242
|
+
console.log(`Goal #${d.goal.id} "${d.goal.title}" moved ${d.from_version_id} → ${d.to_version_id}${d.admitted ? ' (admitted into a building version)' : ''}.`);
|
|
243
|
+
console.log(` tasks moved: ${d.moved_task_ids.length}${d.moved_task_ids.length ? ' (' + d.moved_task_ids.join(', ') + ')' : ''}`);
|
|
244
|
+
console.log(` criteria moved: ${d.moved_criterion_ids.length}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
211
247
|
async function main() {
|
|
212
248
|
await requireSession();
|
|
213
249
|
const args = process.argv.slice(2);
|
|
@@ -219,6 +255,8 @@ async function main() {
|
|
|
219
255
|
} else if (cmd === 'show') {
|
|
220
256
|
if (!/^\d+$/.test(args[1] || '')) usageExit();
|
|
221
257
|
await show(api, Number(args[1]));
|
|
258
|
+
} else if (cmd === 'move') {
|
|
259
|
+
await move(api, args.slice(1));
|
|
222
260
|
} else {
|
|
223
261
|
usageExit();
|
|
224
262
|
}
|
|
@@ -231,4 +269,4 @@ if (require.main === module) {
|
|
|
231
269
|
});
|
|
232
270
|
}
|
|
233
271
|
|
|
234
|
-
module.exports = { rollup, criterionGlyph, criterionState, bucketTasks, liveTasks };
|
|
272
|
+
module.exports = { rollup, criterionGlyph, criterionState, bucketTasks, liveTasks, parseMoveArgs };
|
|
@@ -58,7 +58,11 @@ const SNAPSHOTS = [
|
|
|
58
58
|
['goals/25/conflicts', 'goals__conflicts'],
|
|
59
59
|
['goals/25/requests', 'goals__requests'],
|
|
60
60
|
['tasks/claimable', 'tasks__claimable'],
|
|
61
|
-
|
|
61
|
+
// &include=deps (task 1004487) — the goal page's task cards read `blocked_by[]`
|
|
62
|
+
// to tell a claimable `ready` row from one whose dependencies have not shipped.
|
|
63
|
+
// Without it every row refreshes with no dependency data and the harness can
|
|
64
|
+
// only ever demo the claimable half of that fork.
|
|
65
|
+
['tasks?status=ready&include=deps', 'tasks', trimList('tasks', 60)],
|
|
62
66
|
['tasks/2166', 'tasks__detail'],
|
|
63
67
|
['tasks/peer-votes/feed?limit=25', 'tasks__peer-votes__feed'],
|
|
64
68
|
['analytics/builder/95?days=90', 'analytics__builder'],
|
|
@@ -346,6 +346,9 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
346
346
|
// builder archive/resurrect a shared workspace out from under its members.
|
|
347
347
|
'POST /goals/:id/archive': 'metic+archon',
|
|
348
348
|
'POST /goals/:id/reopen': 'metic+archon',
|
|
349
|
+
// goal re-home across versions (task 1004485) — changes two versions' scope at
|
|
350
|
+
// once, so it carries the version-authoring atom (Archon floor), not a goal one.
|
|
351
|
+
'POST /goals/:id/move': 'perm:version.create',
|
|
349
352
|
// The bounded task-authoring KEYSTONE + self-service membership (ADR 0086 §3,
|
|
350
353
|
// BV1.R60/R61). These are DELIBERATELY 'any-builder': the wall is the IN-HANDLER
|
|
351
354
|
// scope/protected/rank-floor gate (authorizeGoalTaskCreate + goal-scope-check),
|
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.67'; // 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');
|
|
@@ -38,11 +38,32 @@ await test('task.js constructs the shared claim action', () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
await test('task.js renders a Claim button only when the task is ready', () => {
|
|
41
|
-
const m = taskJs.match(/if \(t\.status === 'ready'\) \{\s*lifecycle = `([^`]*)`/);
|
|
41
|
+
const m = taskJs.match(/if \(t\.status === 'ready' && unmetDeps\.length === 0\) \{\s*lifecycle = `([^`]*)`/);
|
|
42
42
|
assert.ok(m, 'no ready-status branch found in the lifecycle chain');
|
|
43
43
|
assert.match(m[1], /id="claim-btn"/);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// Task 1004487 — `ready` alone was never the claim gate's question. A dependency
|
|
47
|
+
// added after promotion leaves the row at `ready` (ADR 0015: the status is
|
|
48
|
+
// deliberately never demoted), and POST /claims refuses it with
|
|
49
|
+
// DEPS_NOT_SHIPPED. core_264 taught the claimable FEED that; this page was the
|
|
50
|
+
// surface it did not reach.
|
|
51
|
+
await test('a dep-blocked ready task gets NO Claim button, and is told why', () => {
|
|
52
|
+
assert.match(taskJs, /const unmetDeps = deps\.filter\(\(d\) => d\.status !== 'shipped'\);/,
|
|
53
|
+
'the unmet-dependency predicate must mirror the gate: met iff the target shipped');
|
|
54
|
+
const refusal = taskJs.match(/\} else if \(t\.status === 'ready'\) \{\s*([\s\S]{0,600}?)\n {4}\} else if/);
|
|
55
|
+
assert.ok(refusal, 'no dep-blocked ready branch after the claimable one');
|
|
56
|
+
assert.doesNotMatch(refusal[1], /claim-btn/, 'a task the gate will refuse must not be offered the button');
|
|
57
|
+
assert.match(refusal[1], /rec-actnote/, 'the refusal is pre-answered in words, like the retire refusals');
|
|
58
|
+
assert.match(refusal[1], /waiting on/, 'and it names what it is waiting on');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test("the lifecycle rail's first beat does not call a dep-blocked task 'waiting for a builder'", () => {
|
|
62
|
+
assert.match(taskJs, /const waitingOnDep = \(t\.dependencies \|\| \[\]\)\.some\(\(d\) => d\.status !== 'shipped'\);/);
|
|
63
|
+
assert.match(taskJs, /t\.status === 'ready' && waitingOnDep\)\s*\n?\s*\? CLAIM_CAPTION\.blocked/,
|
|
64
|
+
"a dep-blocked ready row borrows the 'blocked' caption — waiting on another task");
|
|
65
|
+
});
|
|
66
|
+
|
|
46
67
|
await test('the Claim button click routes through claimAction.claim, not a raw POST', () => {
|
|
47
68
|
const m = taskJs.match(/const claimBtn = \$\('#claim-btn'\);[\s\S]*?\n {4}\}/);
|
|
48
69
|
assert.ok(m, 'claim-btn wiring block not found');
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// tests/dep_blocked_ready_cards.mjs — task 1004487.
|
|
2
|
+
//
|
|
3
|
+
// THE BUG. ADR 0015 rules that a dependency added AFTER a task was promoted
|
|
4
|
+
// leaves the row at `ready`: the status answers "was this promoted?", not "is
|
|
5
|
+
// this workable right now?", so nothing demotes it. `core_264` (task 1004190)
|
|
6
|
+
// taught the claimable FEED to ask the dependency graph instead of the status
|
|
7
|
+
// column. The Goals page's task cards were a surface it did not reach — they
|
|
8
|
+
// read `t.status === 'ready'` alone, so a dep-blocked row got a bulk-claim
|
|
9
|
+
// checkbox and a Claim button for work `POST /claims` refuses with
|
|
10
|
+
// DEPS_NOT_SHIPPED.
|
|
11
|
+
//
|
|
12
|
+
// The predicate itself (`hasUnmetDeps` / `readinessOf`) is proven BEHAVIOURALLY
|
|
13
|
+
// in tests/goals_lib.mjs, and the server-side mirror in
|
|
14
|
+
// tests/goal_sort_staleness.mjs. What is left to pin, and what this file pins,
|
|
15
|
+
// is that the card actually CALLS it — and that the data it needs is asked for,
|
|
16
|
+
// because the predicate is silent (correctly) when `blocked_by[]` is absent, so
|
|
17
|
+
// a dropped `?include=deps` would reopen the bug with every test still green.
|
|
18
|
+
//
|
|
19
|
+
// goals-render.js is a browser IIFE reaching for `window` at eval time, so it is
|
|
20
|
+
// read as text — the idiom tests/claim_action.mjs and claim_from_task_and_home.mjs
|
|
21
|
+
// already use for these files.
|
|
22
|
+
//
|
|
23
|
+
// Run: node tests/dep_blocked_ready_cards.mjs
|
|
24
|
+
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { makeRunner } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const repoRoot = join(__dirname, '..');
|
|
33
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
34
|
+
const { test, summary } = makeRunner();
|
|
35
|
+
|
|
36
|
+
const renderJs = read('modules', 'hall-ui', 'public', 'goals-render.js');
|
|
37
|
+
|
|
38
|
+
await test('the goal page ASKS for the dependency data its cards now read', () => {
|
|
39
|
+
// Without include=deps every task arrives with no blocked_by[], hasUnmetDeps
|
|
40
|
+
// answers false for all of them, and the gate below is a no-op that still passes.
|
|
41
|
+
assert.match(renderJs, /\$\{API\}\/tasks\?goal_id=\$\{encodeURIComponent\(goalId\)\}&include=deps/,
|
|
42
|
+
'loadGoalTasks must request ?include=deps');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await test('a dep-blocked ready card is not offered the claim affordances', () => {
|
|
46
|
+
assert.match(renderJs, /const isReadyNow = t\.status === 'ready' && !hasUnmetDeps\(t\);/,
|
|
47
|
+
"the card's claimable predicate must ask the dependency question, not the status alone");
|
|
48
|
+
// The checkbox and the Claim button both hang off isReady, so gating that one
|
|
49
|
+
// name is what removes both.
|
|
50
|
+
assert.match(renderJs, /const isReady = isReadyNow && !!state\.me;/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('the bulk selection prunes a tick that is no longer claimable', () => {
|
|
54
|
+
// A tick that survives the prune rides into a bulk claim the server refuses,
|
|
55
|
+
// which is the same lie one layer down.
|
|
56
|
+
assert.match(renderJs, /state\.work\.todo\.filter\(\(t\) => t\.status === 'ready' && !hasUnmetDeps\(t\)\)/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('the card takes its lead tag from the lib, never a status echo of its own', () => {
|
|
60
|
+
// Hiding the checkbox without changing the word would leave the row reading
|
|
61
|
+
// "ready" with no affordance and no reason — worse than the bug. The tag is
|
|
62
|
+
// pure, so it lives in goals-lib.js and is proven BEHAVIOURALLY in
|
|
63
|
+
// tests/goals_lib.mjs; what this pins is that the card calls it and that the
|
|
64
|
+
// hand-rolled status span it replaced has not grown back beside it.
|
|
65
|
+
assert.match(renderJs, /: taskStatusTagHtml\(t\)\);/);
|
|
66
|
+
assert.doesNotMatch(renderJs, /<span class="task__tag" title="Task status">/,
|
|
67
|
+
'goals-render.js must not re-grow its own status tag');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
summary();
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// tests/goal_move.mjs — POST /goals/:id/move re-homes a goal, whole, on another
|
|
2
|
+
// version (task 1004485).
|
|
3
|
+
//
|
|
4
|
+
// Three layers, the goal_archive_disposition / goal_archive_route_e2e split in one
|
|
5
|
+
// file: the pure gate's truth table, the writer's transaction shape against a fake
|
|
6
|
+
// pool (every write inside ONE transaction, tasks moved by version_id AND goal,
|
|
7
|
+
// the cross-goal criterion refusal BEFORE any write), and the real router over a
|
|
8
|
+
// real socket with the db layer stubbed at the module boundary.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/goal_move.mjs
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test, after } from 'node:test';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
17
|
+
|
|
18
|
+
process.env.NODE_ENV = 'test';
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { authorizeGoalMove, moveGoalToVersion } = require('../modules/lifecycle/goal-move.js');
|
|
21
|
+
|
|
22
|
+
const OPEN_GOAL = { id: '7', version_id: 'V2', status: 'open', is_maintenance: false };
|
|
23
|
+
const V2 = { id: 'V2', status: 'building' };
|
|
24
|
+
const V3 = { id: 'V3', status: 'planning' };
|
|
25
|
+
|
|
26
|
+
// ---- the pure gate ----------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
test('an open goal moves to the planning version', () => {
|
|
29
|
+
const d = authorizeGoalMove({ goal: OPEN_GOAL, source: V2, target: V3 });
|
|
30
|
+
assert.deepEqual(d, { ok: true, overrideApplied: false });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('refusals, each by name', () => {
|
|
34
|
+
const cases = [
|
|
35
|
+
[{ goal: null, source: null, target: V3 }, 404, 'goal_not_found'],
|
|
36
|
+
[{ goal: { ...OPEN_GOAL, is_maintenance: true }, source: V2, target: V3 }, 409, 'maintenance_goal'],
|
|
37
|
+
[{ goal: { ...OPEN_GOAL, status: 'achieved' }, source: V2, target: V3 }, 409, 'goal_not_open'],
|
|
38
|
+
[{ goal: { ...OPEN_GOAL, status: 'archived' }, source: V2, target: V3 }, 409, 'goal_not_open'],
|
|
39
|
+
[{ goal: OPEN_GOAL, source: { id: 'V2', status: 'shipped' }, target: V3 }, 409, 'source_version_shipped'],
|
|
40
|
+
[{ goal: OPEN_GOAL, source: V2, target: V2 }, 409, 'same_version'],
|
|
41
|
+
[{ goal: OPEN_GOAL, source: V2, target: null }, 404, 'version_not_found'],
|
|
42
|
+
[{ goal: OPEN_GOAL, source: V2, target: { id: 'V1', status: 'shipped' } }, 409, 'version_closed'],
|
|
43
|
+
[{ goal: { ...OPEN_GOAL, version_id: 'V3' }, source: V3, target: V2 }, 409, 'version_not_planning'],
|
|
44
|
+
];
|
|
45
|
+
for (const [input, status, error] of cases) {
|
|
46
|
+
const d = authorizeGoalMove(input);
|
|
47
|
+
assert.equal(d.ok, false, error);
|
|
48
|
+
assert.equal(d.status, status, error);
|
|
49
|
+
assert.equal(d.body.error, error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a building target opens only with the admit_reason override, and says so', () => {
|
|
54
|
+
const goal = { ...OPEN_GOAL, version_id: 'V3' };
|
|
55
|
+
assert.deepEqual(authorizeGoalMove({ goal, source: V3, target: V2, admitReason: 'owner pulled it back' }),
|
|
56
|
+
{ ok: true, overrideApplied: true });
|
|
57
|
+
// On a planning target the reason changes nothing — so nothing is recorded.
|
|
58
|
+
assert.deepEqual(authorizeGoalMove({ goal: OPEN_GOAL, source: V2, target: V3, admitReason: 'x' }),
|
|
59
|
+
{ ok: true, overrideApplied: false });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---- the writer ---------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
|
|
65
|
+
function txPool(handler) {
|
|
66
|
+
const c = makeSqlAwareClient(handler);
|
|
67
|
+
return { connect: c.connect, query: (sql, params) => c._client.query(sql, params), queries: c._client.queries };
|
|
68
|
+
}
|
|
69
|
+
function happyPool(over = {}) {
|
|
70
|
+
return txPool((q) => {
|
|
71
|
+
for (const [re, rows] of Object.entries(over)) if (new RegExp(re).test(q)) return { rows };
|
|
72
|
+
if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: '7', version_id: 'V2', status: 'open' }] };
|
|
73
|
+
if (/FROM versions WHERE id = \$1 FOR SHARE/.test(q)) return { rows: [{ status: 'planning' }] };
|
|
74
|
+
if (/FROM task_criteria/.test(q)) return { rows: [] };
|
|
75
|
+
if (/UPDATE done_when_criteria/.test(q)) return { rows: [{ id: '51' }] };
|
|
76
|
+
if (/UPDATE tasks/.test(q)) return { rows: [{ id: '12' }, { id: '3' }] };
|
|
77
|
+
return { rows: [] };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const ARGS = { goalId: 7, fromVersionId: 'V2', toVersionId: 'V3', targetStatus: 'planning' };
|
|
81
|
+
|
|
82
|
+
test('goal, criteria and every task move inside ONE transaction', async () => {
|
|
83
|
+
const pool = happyPool();
|
|
84
|
+
const out = await moveGoalToVersion(ARGS, { pool });
|
|
85
|
+
assert.deepEqual(out, { fromVersionId: 'V2', toVersionId: 'V3', taskIds: ['3', '12'], criterionIds: ['51'] });
|
|
86
|
+
const sqls = pool.queries.map(flat);
|
|
87
|
+
const at = (re) => sqls.findIndex((s) => re.test(s));
|
|
88
|
+
assert.equal(sqls.filter((s) => s === 'BEGIN').length, 1);
|
|
89
|
+
assert.equal(sqls.filter((s) => s === 'COMMIT').length, 1);
|
|
90
|
+
for (const re of [/^UPDATE goals SET version_id = \$2/, /^UPDATE done_when_criteria SET version_id = \$2 WHERE goal_id = \$1/, /^UPDATE tasks SET version_id = \$2/]) {
|
|
91
|
+
const i = at(re);
|
|
92
|
+
assert.ok(i > at(/^BEGIN$/) && i < at(/^COMMIT$/), `${re} is inside the transaction`);
|
|
93
|
+
}
|
|
94
|
+
// Every task in the goal — not only the open ones — or a shipped task would be
|
|
95
|
+
// left in a goal of another version.
|
|
96
|
+
const taskWrite = sqls[at(/^UPDATE tasks/)];
|
|
97
|
+
assert.match(taskWrite, /WHERE goal_id = \$1 RETURNING/);
|
|
98
|
+
assert.doesNotMatch(taskWrite, /status/);
|
|
99
|
+
// The goal is locked before anything is read or written.
|
|
100
|
+
assert.ok(at(/FROM goals WHERE id = \$1 FOR UPDATE/) < at(/^UPDATE/));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a criterion link crossing the goal edge refuses BEFORE any write, and names the pair', async () => {
|
|
104
|
+
const pool = happyPool({ 'FROM task_criteria': [{ task_id: '12', criterion_id: '900' }] });
|
|
105
|
+
await assert.rejects(moveGoalToVersion(ARGS, { pool }), (e) => {
|
|
106
|
+
assert.equal(e.code, 'CRITERION_LINKS_CROSS_GOAL');
|
|
107
|
+
assert.deepEqual(e.links, [{ task_id: '12', criterion_id: '900' }]);
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
110
|
+
const sqls = pool.queries.map(flat);
|
|
111
|
+
assert.equal(sqls.filter((s) => /^UPDATE|^INSERT/.test(s)).length, 0, 'nothing written');
|
|
112
|
+
assert.ok(sqls.includes('ROLLBACK'));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the crossing test asks "exactly one side inside the goal"', async () => {
|
|
116
|
+
const pool = happyPool();
|
|
117
|
+
await moveGoalToVersion(ARGS, { pool });
|
|
118
|
+
const q = pool.queries.map(flat).find((s) => /FROM task_criteria/.test(s));
|
|
119
|
+
assert.match(q, /\(t\.goal_id IS NOT DISTINCT FROM \$1\) <> \(c\.goal_id IS NOT DISTINCT FROM \$1\)/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a goal or target that changed since the route checked rolls back', async () => {
|
|
123
|
+
for (const [over, code] of [
|
|
124
|
+
[{ 'FROM goals WHERE id = \\$1 FOR UPDATE': [{ id: '7', version_id: 'V9', status: 'open' }] }, 'GOAL_CHANGED'],
|
|
125
|
+
[{ 'FROM goals WHERE id = \\$1 FOR UPDATE': [{ id: '7', version_id: 'V2', status: 'archived' }] }, 'GOAL_CHANGED'],
|
|
126
|
+
[{ 'FROM versions WHERE id = \\$1 FOR SHARE': [{ status: 'building' }] }, 'TARGET_CHANGED'],
|
|
127
|
+
[{ 'FROM goals WHERE id = \\$1 FOR UPDATE': [] }, 'GOAL_NOT_FOUND'],
|
|
128
|
+
]) {
|
|
129
|
+
const pool = happyPool(over);
|
|
130
|
+
await assert.rejects(moveGoalToVersion(ARGS, { pool }), (e) => e.code === code);
|
|
131
|
+
assert.equal(pool.queries.map(flat).filter((s) => /^UPDATE/.test(s)).length, 0, code);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a criterion slug the target already uses becomes CRITERION_ID_COLLISION', async () => {
|
|
136
|
+
const pool = txPool((q) => {
|
|
137
|
+
if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: '7', version_id: 'V2', status: 'open' }] };
|
|
138
|
+
if (/FROM versions WHERE id = \$1 FOR SHARE/.test(q)) return { rows: [{ status: 'planning' }] };
|
|
139
|
+
if (/UPDATE done_when_criteria/.test(q)) throw Object.assign(new Error('dup'), { code: '23505' });
|
|
140
|
+
return { rows: [] };
|
|
141
|
+
});
|
|
142
|
+
await assert.rejects(moveGoalToVersion(ARGS, { pool }), (e) => e.code === 'CRITERION_ID_COLLISION');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('the admission is written only when the override opened a building target', async () => {
|
|
146
|
+
const plain = happyPool();
|
|
147
|
+
await moveGoalToVersion(ARGS, { pool: plain });
|
|
148
|
+
assert.equal(plain.queries.map(flat).filter((s) => /lifecycle_goal_version_admissions/.test(s)).length, 0);
|
|
149
|
+
|
|
150
|
+
const admitted = happyPool({ 'FROM versions WHERE id = \\$1 FOR SHARE': [{ status: 'building' }] });
|
|
151
|
+
await moveGoalToVersion({ ...ARGS, targetStatus: 'building', admission: { admittedBy: '3', reason: 'r' } }, { pool: admitted });
|
|
152
|
+
const ins = admitted.queries.find((q) => /lifecycle_goal_version_admissions/.test(flat(q)));
|
|
153
|
+
assert.ok(ins, 'admission recorded');
|
|
154
|
+
assert.deepEqual(ins.params, [7, 'V3', '3', 'r']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- the route, over a real socket ---------------------------------------------
|
|
158
|
+
|
|
159
|
+
const express = require('express');
|
|
160
|
+
const api = require('../src/module-api.js');
|
|
161
|
+
const db = require('../modules/lifecycle/db.js');
|
|
162
|
+
const goalMove = require('../modules/lifecycle/goal-move.js');
|
|
163
|
+
|
|
164
|
+
let permSeen = null;
|
|
165
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
|
|
166
|
+
api.requirePermission = (...p) => { permSeen = p; return (_req, _res, next) => next(); };
|
|
167
|
+
|
|
168
|
+
const GOALS = { 7: { ...OPEN_GOAL }, 8: { id: '8', version_id: 'V2', status: 'open', is_maintenance: true } };
|
|
169
|
+
const VERSIONS = { V2, V3 };
|
|
170
|
+
let writes = [];
|
|
171
|
+
db.getGoal = async (id) => GOALS[Number(id)] ?? null;
|
|
172
|
+
db.getVersion = async (id) => VERSIONS[id] ?? null;
|
|
173
|
+
goalMove.moveGoalToVersion = async (args) => {
|
|
174
|
+
writes.push(args);
|
|
175
|
+
GOALS[args.goalId] = { ...GOALS[args.goalId], version_id: args.toVersionId };
|
|
176
|
+
return { fromVersionId: args.fromVersionId, toVersionId: args.toVersionId, taskIds: ['3', '12'], criterionIds: [] };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const app = express();
|
|
180
|
+
app.use(express.json());
|
|
181
|
+
app.use((req, res, next) => {
|
|
182
|
+
res.fail = (code, statusOrOpts, details) => {
|
|
183
|
+
const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
|
|
184
|
+
return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
|
|
185
|
+
};
|
|
186
|
+
next();
|
|
187
|
+
});
|
|
188
|
+
app.use('/api/gds', require('../modules/lifecycle/routes/goal-move.js')());
|
|
189
|
+
const server = app.listen(0);
|
|
190
|
+
await new Promise((r) => server.once('listening', r));
|
|
191
|
+
after(() => server.close());
|
|
192
|
+
const base = `http://127.0.0.1:${server.address().port}/api/gds`;
|
|
193
|
+
const post = async (p, body) => {
|
|
194
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
195
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
test('the route is gated on the version-authoring atom', () => {
|
|
199
|
+
assert.deepEqual(permSeen, ['version.create']);
|
|
200
|
+
const pins = readFileSync(new URL('../src/bongos/route-rank-check.js', import.meta.url), 'utf8');
|
|
201
|
+
assert.match(pins, /'POST \/goals\/:id\/move': 'perm:version\.create'/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a move returns the goal on its new version and the tasks that went with it', async () => {
|
|
205
|
+
writes = [];
|
|
206
|
+
const r = await post('/goals/7/move', { version_id: 'V3' });
|
|
207
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
208
|
+
assert.equal(r.body.goal.version_id, 'V3');
|
|
209
|
+
assert.equal(r.body.from_version_id, 'V2');
|
|
210
|
+
assert.deepEqual(r.body.moved_task_ids, ['3', '12']);
|
|
211
|
+
assert.equal(r.body.admitted, false);
|
|
212
|
+
assert.deepEqual(writes, [{ goalId: 7, fromVersionId: 'V2', toVersionId: 'V3', targetStatus: 'planning', admission: null }]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a refused move writes nothing and renders the gate verbatim', async () => {
|
|
216
|
+
writes = [];
|
|
217
|
+
let r = await post('/goals/8/move', { version_id: 'V3' });
|
|
218
|
+
assert.equal(r.status, 409); assert.equal(r.body.error, 'maintenance_goal');
|
|
219
|
+
r = await post('/goals/404/move', { version_id: 'V3' });
|
|
220
|
+
assert.equal(r.status, 404); assert.equal(r.body.error, 'goal_not_found');
|
|
221
|
+
r = await post('/goals/8/move', {});
|
|
222
|
+
assert.equal(r.status, 400);
|
|
223
|
+
r = await post('/goals/8/move', { version_id: 'V3', versionid: 'typo' });
|
|
224
|
+
assert.equal(r.status, 400, 'an unknown field is rejected, not ignored');
|
|
225
|
+
assert.equal(writes.length, 0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('a crossing criterion link surfaces as 409 with the pairs', async () => {
|
|
229
|
+
GOALS[9] = { id: '9', version_id: 'V2', status: 'open', is_maintenance: false };
|
|
230
|
+
const real = goalMove.moveGoalToVersion;
|
|
231
|
+
goalMove.moveGoalToVersion = async () => { throw Object.assign(new Error('x'), { code: 'CRITERION_LINKS_CROSS_GOAL', links: [{ task_id: '1', criterion_id: '2' }] }); };
|
|
232
|
+
const r = await post('/goals/9/move', { version_id: 'V3' });
|
|
233
|
+
goalMove.moveGoalToVersion = real;
|
|
234
|
+
assert.equal(r.status, 409);
|
|
235
|
+
assert.equal(r.body.error, 'criterion_links_cross_goal');
|
|
236
|
+
assert.deepEqual(r.body.details.links, [{ task_id: '1', criterion_id: '2' }]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---- the CLI wrapper ----------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
test('goal.js move parses a real goal id and version id, and refuses junk', () => {
|
|
242
|
+
const { parseMoveArgs } = require('../scripts/gds/goal.js');
|
|
243
|
+
assert.deepEqual(parseMoveArgs(['1000122', '--to', 'BONGOS-V3']), { id: 1000122, body: { version_id: 'BONGOS-V3' } });
|
|
244
|
+
assert.deepEqual(parseMoveArgs(['7', '--to=V3.1', '--admit-reason', 'owner call']),
|
|
245
|
+
{ id: 7, body: { version_id: 'V3.1', admit_reason: 'owner call' } });
|
|
246
|
+
for (const bad of [[], ['abc', '--to', 'V3'], ['7'], ['7', '--to'], ['7', '--to', 'V 3'], ['7', '--to', 'V3;rm']]) {
|
|
247
|
+
assert.equal(parseMoveArgs(bad), null, JSON.stringify(bad));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---- version progress follows the move --------------------------------------
|
|
252
|
+
//
|
|
253
|
+
// The live view counts a version's tasks by tasks.version_id — so the counts move
|
|
254
|
+
// with the goal exactly when the writer re-points every task's version_id, which
|
|
255
|
+
// the transaction test above pins. Both halves are asserted here together: if the
|
|
256
|
+
// view ever counted through goals instead, or the writer stopped touching
|
|
257
|
+
// tasks.version_id, V2's progress would keep counting moved work.
|
|
258
|
+
|
|
259
|
+
test('version_progress counts by tasks.version_id, and the move rewrites it for every task in the goal', async () => {
|
|
260
|
+
const { readdirSync } = await import('node:fs');
|
|
261
|
+
const files = readdirSync(new URL('../migrations/', import.meta.url)).filter((f) => f.endsWith('.sql')).sort();
|
|
262
|
+
const latest = files.filter((f) => /VIEW version_progress/.test(readFileSync(new URL('../migrations/' + f, import.meta.url), 'utf8'))).pop();
|
|
263
|
+
assert.ok(latest, 'version_progress view found');
|
|
264
|
+
const view = readFileSync(new URL('../migrations/' + latest, import.meta.url), 'utf8');
|
|
265
|
+
assert.match(view, /LEFT JOIN tasks t ON t\.version_id = v\.id/);
|
|
266
|
+
|
|
267
|
+
// Before/after against the writer's own statements: a goal holding tasks 3 and
|
|
268
|
+
// 12 on V2 — after the move both carry V3, so V2 counts 0 of them and V3 counts 2.
|
|
269
|
+
const tasks = [{ id: '3', goal_id: 7, version_id: 'V2' }, { id: '12', goal_id: 7, version_id: 'V2' }, { id: '99', goal_id: 8, version_id: 'V2' }];
|
|
270
|
+
const count = (v) => tasks.filter((t) => t.version_id === v).length;
|
|
271
|
+
assert.equal(count('V2'), 3); assert.equal(count('V3'), 0);
|
|
272
|
+
const pool = txPool((q, params) => {
|
|
273
|
+
if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: '7', version_id: 'V2', status: 'open' }] };
|
|
274
|
+
if (/FROM versions WHERE id = \$1 FOR SHARE/.test(q)) return { rows: [{ status: 'planning' }] };
|
|
275
|
+
if (/^UPDATE tasks SET version_id = \$2/.test(q)) {
|
|
276
|
+
const hit = tasks.filter((t) => t.goal_id === params[0]);
|
|
277
|
+
for (const t of hit) t.version_id = params[1];
|
|
278
|
+
return { rows: hit.map((t) => ({ id: t.id })) };
|
|
279
|
+
}
|
|
280
|
+
return { rows: [] };
|
|
281
|
+
});
|
|
282
|
+
await moveGoalToVersion(ARGS, { pool });
|
|
283
|
+
assert.equal(count('V2'), 1, 'only the other goal\'s task still counts on V2');
|
|
284
|
+
assert.equal(count('V3'), 2);
|
|
285
|
+
});
|