@bongos/core 1.19.550 → 1.19.551

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ // tests/task_merge.mjs
2
+ //
3
+ // Regression guard for task 1001478 — db.mergeTask, the idea_inbox merge
4
+ // verdict's twin for `tasks` (the /bug-triage counterpart to /idea-triage's
5
+ // merge). See modules/lifecycle/db-ship.js's mergeTask header for the full
6
+ // design rationale (why 'abandoned' + merged_into_task_id, not a new status).
7
+ //
8
+ // Contract under test:
9
+ // - a live task merges into an existing, unmerged target: status flips to
10
+ // 'abandoned', merged_into_task_id/triage_note/value_summary are written,
11
+ // the txn COMMITs.
12
+ // - self-merge → MERGE_SELF, no DB read even reached.
13
+ // - not found → TASK_NOT_FOUND, ROLLBACK.
14
+ // - 'shipped' → CANNOT_MERGE_SHIPPED, ROLLBACK, no UPDATE (immutable history).
15
+ // - already merged into the SAME target → idempotent no-op (alreadyMerged:true).
16
+ // - already merged into a DIFFERENT target → ALREADY_MERGED, names the
17
+ // existing target (no re-merge chains).
18
+ // - active claim → TASK_HAS_ACTIVE_CLAIM, ROLLBACK, no UPDATE.
19
+ // - the shipped/already-merged guards short-circuit BEFORE the claims lookup.
20
+ // - target doesn't exist → MERGE_TARGET_NOT_FOUND.
21
+ // - target is itself merged elsewhere → MERGE_TARGET_MERGED, names where the
22
+ // chain ends (mirrors idea merge's target-COLUMN check, not target status).
23
+ // - no reason → value_summary is PRESERVED (no synthesized default;
24
+ // merged_into_task_id is the queryable source of truth).
25
+ //
26
+ // mergeTask opens its own transaction via pool.connect(); the #538 deps.pool
27
+ // injection seam (honored ONLY when NODE_ENV==='test') drives it with a
28
+ // SQL-aware fakeClient — no live DB — same posture as abandon_task.mjs.
29
+ //
30
+ // Run: node tests/task_merge.mjs
31
+
32
+ import { strict as assert } from 'node:assert';
33
+ import { createRequire } from 'node:module';
34
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
35
+
36
+ process.env.NODE_ENV = 'test';
37
+
38
+ const require = createRequire(import.meta.url);
39
+ const db = require('../modules/lifecycle/db.js');
40
+ const { mergeTask } = db;
41
+
42
+ const { test, summary } = makeRunner();
43
+
44
+ // SQL-aware fake keyed on the two SELECTs mergeTask issues, by which id they
45
+ // target — the source row (FOR UPDATE, taskId) and the target row (no lock,
46
+ // intoTaskId). Distinguishing by bound param (not statement text, since both
47
+ // are structurally different queries here) mirrors abandon_task.mjs's shape.
48
+ function fakePool({
49
+ status = 'ready', notFound = false, mergedInto = null,
50
+ hasActiveClaim = false, targetNotFound = false, targetMergedInto = null,
51
+ updatedStatus = 'abandoned',
52
+ } = {}) {
53
+ return makeSqlAwareClient((s, params) => {
54
+ if (/^SELECT id, title, status, merged_into_task_id FROM tasks WHERE id = \$1 FOR UPDATE/.test(s)) {
55
+ return { rows: notFound ? [] : [{ id: params[0], title: 'fixture bug', status, merged_into_task_id: mergedInto }] };
56
+ }
57
+ if (/^SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL/.test(s)) {
58
+ return { rows: hasActiveClaim ? [{ id: 999 }] : [] };
59
+ }
60
+ if (/^SELECT id, merged_into_task_id FROM tasks WHERE id = \$1/.test(s)) {
61
+ return { rows: targetNotFound ? [] : [{ id: params[0], merged_into_task_id: targetMergedInto }] };
62
+ }
63
+ if (/^UPDATE tasks/.test(s)) {
64
+ return { rows: [{ id: params[0], title: 'fixture bug', status: updatedStatus }] };
65
+ }
66
+ return { rows: [] }; // BEGIN / COMMIT / ROLLBACK
67
+ });
68
+ }
69
+
70
+ const find = (queries, re) => queries.find((q) => re.test(String(q.sql).replace(/\s+/g, ' ')));
71
+ const all = (queries, re) => queries.filter((q) => re.test(String(q.sql).replace(/\s+/g, ' ')));
72
+
73
+ const TASK = 1001;
74
+ const TARGET = 2002;
75
+
76
+ // =========================================================================
77
+ await test('merge: a ready task merges into an existing target, COMMITs', async () => {
78
+ const pool = fakePool({ status: 'ready' });
79
+ const out = await mergeTask({ taskId: TASK, intoTaskId: TARGET, reason: 'same report' }, { pool });
80
+
81
+ assert.equal(out.taskStatus, 'abandoned');
82
+ assert.equal(out.previousStatus, 'ready');
83
+ assert.equal(out.mergedIntoTaskId, TARGET);
84
+ assert.equal(out.alreadyMerged, false);
85
+ assert.ok(find(pool._client.queries, /^UPDATE tasks/), 'the tasks UPDATE must run');
86
+ assert.ok(find(pool._client.queries, /^COMMIT/), 'must COMMIT');
87
+ assert.ok(!find(pool._client.queries, /^ROLLBACK/), 'no ROLLBACK on the happy path');
88
+ });
89
+
90
+ await test('merge: writes merged_into_task_id + triage_note + reason-derived value_summary', async () => {
91
+ const pool = fakePool({ status: 'ready' });
92
+ await mergeTask({ taskId: TASK, intoTaskId: TARGET, reason: 'dup of #2002' }, { pool });
93
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
94
+ assert.equal(upd.params[0], TASK);
95
+ assert.equal(upd.params[1], TARGET);
96
+ assert.equal(upd.params[2], 'dup of #2002');
97
+ assert.match(String(upd.sql), /merged_into_task_id = \$2/);
98
+ assert.match(String(upd.sql), /triage_note = NULLIF/);
99
+ });
100
+
101
+ await test('merge: no reason PRESERVES the existing value_summary (no synthesized default)', async () => {
102
+ // Round-2 grade finding: an earlier version fell back to a synthesized
103
+ // "Merged into #<N>" default, which unconditionally overwrote a
104
+ // completed/confirmed bug task's real value_summary. mergeTask now matches
105
+ // abandonTask's own COALESCE(NULLIF($n,''), value_summary) shape exactly —
106
+ // merged_into_task_id is the queryable source of truth for where it went.
107
+ const pool = fakePool({ status: 'ready' });
108
+ await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool });
109
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
110
+ assert.equal(upd.params[2], '', 'empty reason binds empty string');
111
+ assert.match(String(upd.sql), /COALESCE\(NULLIF\(\$3, ''\), value_summary\)/);
112
+ });
113
+
114
+ await test('merge: self-merge is refused before any SELECT (right after BEGIN)', async () => {
115
+ const pool = fakePool({ status: 'ready' });
116
+ await assert.rejects(
117
+ () => mergeTask({ taskId: TASK, intoTaskId: TASK }, { pool }),
118
+ (err) => err.code === 'MERGE_SELF',
119
+ );
120
+ assert.equal(all(pool._client.queries, /^SELECT/).length, 0, 'no SELECT reached — the check runs before the task row is even locked');
121
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
122
+ });
123
+
124
+ await test('merge: a missing task throws TASK_NOT_FOUND and rolls back', async () => {
125
+ const pool = fakePool({ notFound: true });
126
+ await assert.rejects(
127
+ () => mergeTask({ taskId: 424242, intoTaskId: TARGET }, { pool }),
128
+ (err) => err.code === 'TASK_NOT_FOUND',
129
+ );
130
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
131
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
132
+ });
133
+
134
+ await test('merge: a shipped task is refused (immutable history) and rolls back', async () => {
135
+ const pool = fakePool({ status: 'shipped' });
136
+ await assert.rejects(
137
+ () => mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }),
138
+ (err) => err.code === 'CANNOT_MERGE_SHIPPED',
139
+ );
140
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
141
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
142
+ });
143
+
144
+ await test('merge: the shipped guard short-circuits BEFORE the claims lookup', async () => {
145
+ const pool = fakePool({ status: 'shipped' });
146
+ await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }).catch(() => {});
147
+ assert.equal(all(pool._client.queries, /SELECT id FROM claims/).length, 0,
148
+ 'a shipped task must not even query claims');
149
+ });
150
+
151
+ await test('merge: already merged into the SAME target is an idempotent no-op', async () => {
152
+ const pool = fakePool({ status: 'abandoned', mergedInto: TARGET });
153
+ const out = await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool });
154
+ assert.equal(out.alreadyMerged, true);
155
+ assert.equal(out.mergedIntoTaskId, TARGET);
156
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0, 'idempotent — no second UPDATE');
157
+ assert.equal(all(pool._client.queries, /SELECT id FROM claims/).length, 0, 'no claims lookup needed');
158
+ assert.ok(find(pool._client.queries, /^COMMIT/), 'still COMMITs the (empty) txn');
159
+ });
160
+
161
+ await test('merge: already merged into a DIFFERENT target is refused, naming the existing one', async () => {
162
+ const pool = fakePool({ status: 'abandoned', mergedInto: 5005 });
163
+ const err = await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }).catch((e) => e);
164
+ assert.equal(err.code, 'ALREADY_MERGED');
165
+ assert.equal(err.merged_into, '5005');
166
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
167
+ });
168
+
169
+ await test('merge: a task with an active claim is refused (release first) and rolls back', async () => {
170
+ const pool = fakePool({ status: 'ready', hasActiveClaim: true });
171
+ await assert.rejects(
172
+ () => mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }),
173
+ (err) => err.code === 'TASK_HAS_ACTIVE_CLAIM',
174
+ );
175
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
176
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
177
+ });
178
+
179
+ await test('merge: a nonexistent target throws MERGE_TARGET_NOT_FOUND and rolls back', async () => {
180
+ const pool = fakePool({ status: 'ready', targetNotFound: true });
181
+ await assert.rejects(
182
+ () => mergeTask({ taskId: TASK, intoTaskId: 999999 }, { pool }),
183
+ (err) => err.code === 'MERGE_TARGET_NOT_FOUND',
184
+ );
185
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
186
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
187
+ });
188
+
189
+ await test('merge: a target that is itself merged elsewhere is refused, naming where the chain ends', async () => {
190
+ const pool = fakePool({ status: 'ready', targetMergedInto: 7007 });
191
+ const err = await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }).catch((e) => e);
192
+ assert.equal(err.code, 'MERGE_TARGET_MERGED');
193
+ assert.equal(err.merged_into, '7007');
194
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
195
+ });
196
+
197
+ // -------------------------------------------------------------------------
198
+ summary();