@bongos/core 1.19.1057 → 1.19.1059
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 +88 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +235 -2
- package/docs/api-reference.md +7 -2
- package/docs/architecture.md +2 -1
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1178 -0
- package/docs/page-readings.json +4108 -0
- package/modules/copy-desk/flags.js +84 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/page-data.js +79 -0
- package/modules/copy-desk/page-status.js +357 -0
- package/modules/copy-desk/pages.js +238 -0
- package/modules/copy-desk/routes/copy-desk.js +357 -4
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +63 -17
- package/modules/copy-desk/tests/copy_page_status.mjs +240 -0
- package/modules/copy-desk/tests/fixtures/page-tweak.cjs +154 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/modules/lifecycle/page-tweak-reads.js +87 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/publish-manifest.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +44 -0
- package/tests/copy_desk_page_claim.mjs +334 -0
- package/tests/copy_desk_page_reads.mjs +194 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// tests/copy_desk_page_claim.mjs — claim a page, and ask for one, the WHOLE path
|
|
2
|
+
// (task 1004317 / BV2.TW06, ADR 0341 D2, D5, D6).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: an artist claims a page; a second artist is refused with the
|
|
5
|
+
// holder named; a non-artist can ask but not claim.
|
|
6
|
+
//
|
|
7
|
+
// Driven through the real copy-desk routes, the real lifecycle port
|
|
8
|
+
// (webClaimPageTweak, and the real createTask under it), the real flag SQL and
|
|
9
|
+
// the real error envelope, against a STATEFUL fake pool: a small in-memory
|
|
10
|
+
// tasks / claims / builders / copy_desk_flags ledger that answers the SQL those
|
|
11
|
+
// paths send. The fake models the page's advisory lock as a real mutex, so the
|
|
12
|
+
// "two artists at once" case is a race the lock has to win, not two calls in a
|
|
13
|
+
// row.
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
|
|
22
|
+
process.env.NODE_ENV = 'test';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
25
|
+
const api = require('../src/module-api.js');
|
|
26
|
+
|
|
27
|
+
// --- the ledger -------------------------------------------------------------
|
|
28
|
+
const db = {
|
|
29
|
+
builders: new Map([
|
|
30
|
+
['7', { id: '7', status: 'active', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] }],
|
|
31
|
+
['8', { id: '8', status: 'active', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['engineer', 'artist'] }],
|
|
32
|
+
['9', { id: '9', status: 'active', github_login: 'carol', display_name: 'Carol', preferred_disciplines: ['engineer'] }],
|
|
33
|
+
]),
|
|
34
|
+
tasks: [],
|
|
35
|
+
claims: [],
|
|
36
|
+
flags: [],
|
|
37
|
+
seq: { task: 5000, claim: 9000, flag: 100 },
|
|
38
|
+
building: true,
|
|
39
|
+
};
|
|
40
|
+
const ALICE = '7';
|
|
41
|
+
const BOB = '8';
|
|
42
|
+
const CAROL = '9';
|
|
43
|
+
|
|
44
|
+
// The advisory lock, as a mutex per key: a second locker waits until the first
|
|
45
|
+
// commits or rolls back. That is what makes the race case below a real race.
|
|
46
|
+
const locks = new Map();
|
|
47
|
+
async function lock(key, owner) {
|
|
48
|
+
while (locks.has(key)) await locks.get(key).done;
|
|
49
|
+
let release;
|
|
50
|
+
const done = new Promise((r) => { release = r; });
|
|
51
|
+
locks.set(key, { owner, done, release });
|
|
52
|
+
}
|
|
53
|
+
function unlockAll(owner) {
|
|
54
|
+
for (const [k, v] of [...locks]) if (v.owner === owner) { locks.delete(k); v.release(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
58
|
+
const seen = [];
|
|
59
|
+
let clientSeq = 0;
|
|
60
|
+
|
|
61
|
+
async function answer(q, p, owner) {
|
|
62
|
+
seen.push(q);
|
|
63
|
+
// Yield on every statement, so two concurrent requests really interleave:
|
|
64
|
+
// without the page lock, both would read "no open round" and both create.
|
|
65
|
+
await new Promise((r) => setImmediate(r));
|
|
66
|
+
if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) {
|
|
67
|
+
if (q !== 'BEGIN') unlockAll(owner);
|
|
68
|
+
return { rows: [] };
|
|
69
|
+
}
|
|
70
|
+
if (/pg_advisory_xact_lock\(hashtext\(\$1\)\)/.test(q)) {
|
|
71
|
+
assert.ok(owner, 'the page lock must be taken on the transaction client');
|
|
72
|
+
await lock(p[0], owner);
|
|
73
|
+
return { rows: [] };
|
|
74
|
+
}
|
|
75
|
+
if (/^SELECT id, status, github_login, display_name FROM builders WHERE id = \$1/.test(q)) {
|
|
76
|
+
const b = db.builders.get(String(p[0]));
|
|
77
|
+
return { rows: b ? [b] : [] };
|
|
78
|
+
}
|
|
79
|
+
if (/^SELECT preferred_disciplines FROM builders WHERE id = \$1/.test(q)) {
|
|
80
|
+
const b = db.builders.get(String(p[0]));
|
|
81
|
+
return { rows: b ? [{ preferred_disciplines: b.preferred_disciplines }] : [] };
|
|
82
|
+
}
|
|
83
|
+
if (/FROM tasks WHERE source = \$1 AND left\(source_ref, length\(\$2\)\) = \$2 ORDER BY id ASC FOR UPDATE/.test(q)) {
|
|
84
|
+
return { rows: db.tasks.filter((t) => t.source === p[0] && String(t.source_ref).startsWith(p[1])).map((t) => ({ ...t })) };
|
|
85
|
+
}
|
|
86
|
+
if (/FROM claims c JOIN builders b ON b\.id = c\.builder_id WHERE c\.task_id = \$1 AND c\.released_at IS NULL/.test(q)) {
|
|
87
|
+
const c = db.claims.find((x) => String(x.task_id) === String(p[0]) && x.released_at == null);
|
|
88
|
+
if (!c) return { rows: [] };
|
|
89
|
+
const b = db.builders.get(String(c.builder_id));
|
|
90
|
+
return { rows: [{ ...c, github_login: b.github_login, display_name: b.display_name }] };
|
|
91
|
+
}
|
|
92
|
+
if (/^INSERT INTO claims \(task_id, builder_id, worktree_name, requested_worktree_name, head_sha, creator_session_id\) VALUES \(\$1, \$2, NULL, NULL, NULL, NULL\)/.test(q)) {
|
|
93
|
+
if (db.claims.some((x) => String(x.task_id) === String(p[0]) && x.released_at == null)) {
|
|
94
|
+
throw Object.assign(new Error('duplicate key'), { code: '23505', constraint: 'uniq_active_claim_per_task' });
|
|
95
|
+
}
|
|
96
|
+
const c = { id: String(++db.seq.claim), task_id: String(p[0]), builder_id: String(p[1]), worktree_name: null, creator_session_id: null, claimed_at: new Date().toISOString(), released_at: null };
|
|
97
|
+
db.claims.push(c);
|
|
98
|
+
return { rows: [{ ...c }] };
|
|
99
|
+
}
|
|
100
|
+
if (/^UPDATE tasks SET status = 'active', updated_at = now\(\) WHERE id = \$1/.test(q)) {
|
|
101
|
+
db.tasks.find((t) => String(t.id) === String(p[0])).status = 'active';
|
|
102
|
+
return { rows: [] };
|
|
103
|
+
}
|
|
104
|
+
// createTask's reads: the catch-all goal of the version, and its scope.
|
|
105
|
+
if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
|
|
106
|
+
return { rows: [{ id: '77', version_status: 'building' }] };
|
|
107
|
+
}
|
|
108
|
+
if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
|
|
109
|
+
if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
|
|
110
|
+
assert.ok(owner, 'the round must be created inside the claim transaction');
|
|
111
|
+
const t = {
|
|
112
|
+
id: String(++db.seq.task), version_id: p[0], title: p[1], description: p[2], status: p[3], touches: p[4],
|
|
113
|
+
credits_reward: p[10], source: p[11], source_ref: p[12], kind: p[14], discipline: p[15], requires_rank: p[20], goal_id: p[22], created_by: p[23],
|
|
114
|
+
};
|
|
115
|
+
db.tasks.push(t);
|
|
116
|
+
return { rows: [{ ...t }] };
|
|
117
|
+
}
|
|
118
|
+
if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
|
|
119
|
+
return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
|
|
120
|
+
}
|
|
121
|
+
// The flag SQL (the page ask).
|
|
122
|
+
if (/FROM copy_desk_flags f WHERE f\.scope = 'page' AND f\.page_id = \$1 AND f\.flagged_by = \$2 AND f\.status = 'open'/.test(q)) {
|
|
123
|
+
return { rows: db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && String(x.flagged_by) === String(p[1]) && x.status === 'open').slice(0, 1) };
|
|
124
|
+
}
|
|
125
|
+
if (/^INSERT INTO copy_desk_flags/.test(q)) {
|
|
126
|
+
const [scope, surface, stringId, reason, kind, , , , , by, pageId] = p;
|
|
127
|
+
// The CHECKs of copy_desk_002, so the fake refuses what Postgres would.
|
|
128
|
+
assert.equal((scope === 'page'), (pageId != null), 'copy_desk_flags_target_chk');
|
|
129
|
+
if (scope === 'page') assert.equal(surface, pageId, 'copy_desk_flags_page_surface_chk');
|
|
130
|
+
const dup = db.flags.find((x) => x.status === 'open' && String(x.flagged_by) === String(by) && x.surface === surface && (x.string_id || '') === (stringId || ''));
|
|
131
|
+
if (dup) throw Object.assign(new Error('dup'), { code: '23505' });
|
|
132
|
+
const row = { id: String(++db.seq.flag), scope, surface, string_id: stringId, page_id: pageId, reason, kind, flagged_by: String(by), flagged_at: new Date().toISOString(), status: 'open' };
|
|
133
|
+
db.flags.push(row);
|
|
134
|
+
return { rows: [{ ...row }] };
|
|
135
|
+
}
|
|
136
|
+
if (/COUNT\(DISTINCT flagged_by\)::int AS askers FROM copy_desk_flags WHERE scope = 'page' AND page_id = \$1 AND status = 'open'/.test(q)) {
|
|
137
|
+
return { rows: [{ askers: new Set(db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && x.status === 'open').map((x) => x.flagged_by)).size }] };
|
|
138
|
+
}
|
|
139
|
+
if (/^UPDATE copy_desk_flags SET status = 'resolved', resolved_task_id = \$2, resolved_by = \$3/.test(q)) {
|
|
140
|
+
const hit = db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && x.status === 'open' && new Date(x.flagged_at) <= new Date(p[3]));
|
|
141
|
+
for (const x of hit) Object.assign(x, { status: 'resolved', resolved_task_id: String(p[1]), resolved_by: String(p[2]), resolution_note: p[4] });
|
|
142
|
+
return { rows: hit.map((x) => ({ id: x.id })) };
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
api.pool = {
|
|
148
|
+
query: (sql, params = []) => answer(flat(sql), params, null),
|
|
149
|
+
async connect() {
|
|
150
|
+
const owner = ++clientSeq;
|
|
151
|
+
return { query: (sql, params = []) => answer(flat(sql), params, owner), release() { unlockAll(owner); } };
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
api.requireBuilder = (req, res, next) => {
|
|
156
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
157
|
+
next();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const seams = require('../src/module-seams.js');
|
|
161
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
162
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
163
|
+
|
|
164
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
165
|
+
const express = require('express');
|
|
166
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
167
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
168
|
+
|
|
169
|
+
const app = express();
|
|
170
|
+
app.use(express.json());
|
|
171
|
+
app.use(attachFail);
|
|
172
|
+
app.use(buildRouter());
|
|
173
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
174
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
175
|
+
|
|
176
|
+
async function post(p, builder, body = {}) {
|
|
177
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', 'x-builder': builder }, body: JSON.stringify(body) });
|
|
178
|
+
return { status: r.status, body: await r.json() };
|
|
179
|
+
}
|
|
180
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
181
|
+
const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
182
|
+
|
|
183
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-claim-'));
|
|
184
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
185
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
186
|
+
|
|
187
|
+
const { test, summary } = makeRunner();
|
|
188
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
pageData._setRoot(tmp);
|
|
192
|
+
|
|
193
|
+
await test('an artist claims a page: one round is created and web-claimed, in the building version', async () => {
|
|
194
|
+
const { status, body } = await post(`${STUDIO}/claim`, ALICE);
|
|
195
|
+
assert.equal(status, 201, JSON.stringify(body));
|
|
196
|
+
assert.equal(body.outcome, 'created');
|
|
197
|
+
assert.equal(body.round.state, 'writing');
|
|
198
|
+
assert.equal(body.round.round, 1);
|
|
199
|
+
assert.equal(body.round.source_ref, 'page-tweak/builders:studio/r1');
|
|
200
|
+
const t = db.tasks[0];
|
|
201
|
+
assert.equal(db.tasks.length, 1);
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
[t.source, t.source_ref, t.title, t.kind, t.discipline, t.credits_reward, t.version_id, t.goal_id, t.status],
|
|
204
|
+
['page-tweak', 'page-tweak/builders:studio/r1', 'Tweak: Studio (builders:studio)', 'feature', 'artist', 30, 'V9', '77', 'active']
|
|
205
|
+
);
|
|
206
|
+
assert.deepEqual(t.touches, F.STUDIO_FILES, 'touches are the inventory page files');
|
|
207
|
+
const c = db.claims[0];
|
|
208
|
+
assert.deepEqual([c.builder_id, c.worktree_name, c.creator_session_id], [ALICE, null, null], 'a web claim: no worktree, no session');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await test('the holder claiming again is Resume: the same round, nothing new', async () => {
|
|
212
|
+
const { status, body } = await post(`${STUDIO}/claim`, ALICE);
|
|
213
|
+
assert.equal(status, 200);
|
|
214
|
+
assert.equal(body.outcome, 'resumed');
|
|
215
|
+
assert.equal(body.round.task_id, db.tasks[0].id);
|
|
216
|
+
assert.equal(db.tasks.length, 1);
|
|
217
|
+
assert.equal(db.claims.length, 1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('a second artist is refused page_held, with the holder named', async () => {
|
|
221
|
+
const { status, body } = await post(`${STUDIO}/claim`, BOB);
|
|
222
|
+
assert.equal(status, 409);
|
|
223
|
+
assert.equal(errCode(body), 'page_held');
|
|
224
|
+
assert.deepEqual(errDetail(body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
|
|
225
|
+
assert.match(errDetail(body).what_this_means, /Alice is writing this page/);
|
|
226
|
+
assert.equal(db.tasks.length, 1);
|
|
227
|
+
assert.equal(db.claims.length, 1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await test('a non-artist is refused not_an_artist and offered the ask, and nothing is written', async () => {
|
|
231
|
+
const before = seen.length;
|
|
232
|
+
const { status, body } = await post('/copy-desk/pages/landing:index/claim', CAROL);
|
|
233
|
+
assert.equal(status, 403);
|
|
234
|
+
assert.equal(errCode(body), 'not_an_artist');
|
|
235
|
+
assert.ok(errDetail(body).your_options.some((o) => /asks/.test(o)));
|
|
236
|
+
assert.equal(seen.slice(before).some((q) => /^INSERT/.test(q)), false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('a non-artist CAN ask: a page-scoped flag row, and a second ask returns the first', async () => {
|
|
240
|
+
const first = await post('/copy-desk/pages/landing:index/asks', CAROL, { reason: 'The hero says nothing about what this is.' });
|
|
241
|
+
assert.equal(first.status, 201, JSON.stringify(first.body));
|
|
242
|
+
assert.equal(first.body.created, true);
|
|
243
|
+
assert.deepEqual([first.body.ask.scope, first.body.ask.page_id, first.body.ask.surface], ['page', 'landing:index', 'landing:index']);
|
|
244
|
+
assert.equal(first.body.askers, 1);
|
|
245
|
+
const again = await post('/copy-desk/pages/landing:index/asks', CAROL, { reason: 'Asking a second time, same page.' });
|
|
246
|
+
assert.equal(again.status, 200);
|
|
247
|
+
assert.equal(again.body.created, false);
|
|
248
|
+
assert.equal(again.body.ask.id, first.body.ask.id);
|
|
249
|
+
const other = await post('/copy-desk/pages/landing:index/asks', BOB, { reason: 'Agreed, the hero needs a line.' });
|
|
250
|
+
assert.equal(other.status, 201);
|
|
251
|
+
assert.equal(other.body.askers, 2, 'distinct askers');
|
|
252
|
+
// One builder may ask for several pages of one surface (the reason surface = page id).
|
|
253
|
+
const second = await post('/copy-desk/pages/landing:projects/asks', CAROL, { reason: 'The project cards read the same.' });
|
|
254
|
+
assert.equal(second.status, 201);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('an ask keeps the reason floor; an unknown page is unknown_page; the body is strict', async () => {
|
|
258
|
+
const thin = await post('/copy-desk/pages/landing:index/asks', BOB, { reason: 'fix' });
|
|
259
|
+
assert.equal(errCode(thin.body), 'reason_too_thin');
|
|
260
|
+
const unknown = await post('/copy-desk/pages/landing:nope/claim', ALICE);
|
|
261
|
+
assert.equal(unknown.status, 404);
|
|
262
|
+
assert.equal(errCode(unknown.body), 'unknown_page');
|
|
263
|
+
const smuggled = await post('/copy-desk/pages/landing:index/claim', ALICE, { builder_id: BOB });
|
|
264
|
+
assert.equal(errCode(smuggled.body), 'validation_failed');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await test('two artists claiming one page at once: exactly one round, the other named as held', async () => {
|
|
268
|
+
const [a, b] = await Promise.all([
|
|
269
|
+
post('/copy-desk/pages/status:explore/claim', ALICE),
|
|
270
|
+
post('/copy-desk/pages/status:explore/claim', BOB),
|
|
271
|
+
]);
|
|
272
|
+
const statuses = [a.status, b.status].sort();
|
|
273
|
+
assert.deepEqual(statuses, [201, 409]);
|
|
274
|
+
const loser = a.status === 409 ? a : b;
|
|
275
|
+
assert.equal(errCode(loser.body), 'page_held');
|
|
276
|
+
assert.equal(db.tasks.filter((t) => t.source_ref.startsWith('page-tweak/status:explore/')).length, 1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await test('an unheld draft is taken over; a submitted round is page_in_flight', async () => {
|
|
280
|
+
// Alice releases her studio round without submitting: it is a draft.
|
|
281
|
+
const studio = db.tasks[0];
|
|
282
|
+
db.claims.find((c) => c.task_id === studio.id).released_at = new Date().toISOString();
|
|
283
|
+
studio.status = 'ready';
|
|
284
|
+
studio.description += '\n\n' + require('../modules/copy-desk/pages.js').composeDraftBlock({ page_id: 'builders:studio', reading_hash: 'h', saved_at: 'x', lines: [] });
|
|
285
|
+
const taken = await post(`${STUDIO}/claim`, BOB);
|
|
286
|
+
assert.equal(taken.status, 200, JSON.stringify(taken.body));
|
|
287
|
+
assert.equal(taken.body.outcome, 'claimed');
|
|
288
|
+
assert.equal(taken.body.round.task_id, studio.id, 'the same round: the draft stays');
|
|
289
|
+
assert.match(studio.description, /```page-tweak-draft/);
|
|
290
|
+
|
|
291
|
+
// Bob submits: the claim is released and a batch is frozen.
|
|
292
|
+
db.claims.find((c) => c.task_id === studio.id && c.released_at == null).released_at = new Date().toISOString();
|
|
293
|
+
studio.status = 'ready';
|
|
294
|
+
studio.description += '\n\n```page-tweak\n{"schema":1}\n```';
|
|
295
|
+
const refused = await post(`${STUDIO}/claim`, ALICE);
|
|
296
|
+
assert.equal(refused.status, 409);
|
|
297
|
+
assert.equal(errCode(refused.body), 'page_in_flight');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await test('after a round ships, the next claim opens round 2, and the open asks resolve', async () => {
|
|
301
|
+
const studio = db.tasks[0];
|
|
302
|
+
studio.status = 'shipped';
|
|
303
|
+
studio.shipped_by = BOB;
|
|
304
|
+
studio.shipped_at = new Date(Date.now() + 1000).toISOString();
|
|
305
|
+
await post('/copy-desk/pages/builders:studio/asks', CAROL, { reason: 'Studio greeting reads cold.' });
|
|
306
|
+
// The ship event: the port's getTask is the one read it needs.
|
|
307
|
+
const resolved = await buildRouter.resolveAsksOnShip({ id: studio.id }, {
|
|
308
|
+
lifecycle: { getTask: async () => ({ ...studio }) },
|
|
309
|
+
});
|
|
310
|
+
assert.equal(resolved.length, 1);
|
|
311
|
+
const ask = db.flags.find((x) => x.page_id === 'builders:studio');
|
|
312
|
+
assert.deepEqual([ask.status, ask.resolved_task_id, ask.resolved_by], ['resolved', studio.id, BOB]);
|
|
313
|
+
// Not a page tweak: nothing is touched.
|
|
314
|
+
assert.deepEqual(await buildRouter.resolveAsksOnShip({ id: '1' }, { lifecycle: { getTask: async () => ({ id: '1', source: 'copy-proposal', status: 'shipped' }) } }), []);
|
|
315
|
+
|
|
316
|
+
const next = await post(`${STUDIO}/claim`, ALICE);
|
|
317
|
+
assert.equal(next.status, 201);
|
|
318
|
+
assert.equal(next.body.round.source_ref, 'page-tweak/builders:studio/r2');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await test('nothing building: a new round is no_open_version, and nothing is created', async () => {
|
|
322
|
+
db.building = false;
|
|
323
|
+
const before = db.tasks.length;
|
|
324
|
+
const r = await post('/copy-desk/pages/landing:projects/claim', ALICE);
|
|
325
|
+
assert.equal(r.status, 503);
|
|
326
|
+
assert.equal(errCode(r.body), 'no_open_version');
|
|
327
|
+
assert.equal(db.tasks.length, before);
|
|
328
|
+
db.building = true;
|
|
329
|
+
});
|
|
330
|
+
} finally {
|
|
331
|
+
server.close();
|
|
332
|
+
pageData._setRoot(null);
|
|
333
|
+
}
|
|
334
|
+
summary();
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// tests/copy_desk_page_reads.mjs — Tweak Mode's three reads, the WHOLE path
|
|
2
|
+
// (task 1004316 / BV2.TW05, ADR 0341 D1, D8, D11).
|
|
3
|
+
//
|
|
4
|
+
// modules/copy-desk/tests/copy_page_status.mjs proves the derivation on
|
|
5
|
+
// hand-shaped rows. This proves the path those rows actually travel: the real
|
|
6
|
+
// lifecycle port's SQL (listPageTweakTasks, shippedTasksTouchingSince) and the
|
|
7
|
+
// real reward port's creditLedger, answered by a fake pool, through the real
|
|
8
|
+
// copy-desk routes and the real error envelope, reading the page artifacts from
|
|
9
|
+
// disk through page-data.js. A field the SQL names and the derivation does not
|
|
10
|
+
// read (or the reverse) reds here and nowhere else.
|
|
11
|
+
//
|
|
12
|
+
// And the degrade (ADR 0341 D1): an instance with no page inventory answers a
|
|
13
|
+
// named 503 carrying the generator command, never a crash, while the tally,
|
|
14
|
+
// which needs no inventory, still answers; an instance with the inventory but
|
|
15
|
+
// no readings still reports status and marks drift unknown.
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { makeRunner } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
|
|
30
|
+
// The fake pool, dispatching on the SQL each port read sends. Installed BEFORE
|
|
31
|
+
// the ports are required: economy/credits.js destructures the pool at load.
|
|
32
|
+
const seen = [];
|
|
33
|
+
const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
34
|
+
api.pool = {
|
|
35
|
+
async query(sql, params = []) {
|
|
36
|
+
const q = flat(sql);
|
|
37
|
+
seen.push({ q, params });
|
|
38
|
+
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
39
|
+
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
40
|
+
// The fixture rows stand in for this SELECT, so every column they carry
|
|
41
|
+
// must be one the SELECT really names: a renamed alias would otherwise
|
|
42
|
+
// leave the fake answering with a field the real query never returns.
|
|
43
|
+
const select = q.slice(q.indexOf(') SELECT '), q.indexOf(' FROM tasks t LEFT JOIN'));
|
|
44
|
+
// One pass over claims for the round's artist, never a per-task subquery.
|
|
45
|
+
assert.doesNotMatch(q, /LATERAL/);
|
|
46
|
+
for (const col of Object.keys(F.TASKS[0])) {
|
|
47
|
+
assert.match(select, new RegExp(`(?:t\\.${col}\\b|AS ${col}\\b)`), `the SELECT must name ${col}`);
|
|
48
|
+
}
|
|
49
|
+
return { rows: F.TASKS };
|
|
50
|
+
}
|
|
51
|
+
if (/t\.touches && \$1::text\[\]/.test(q)) {
|
|
52
|
+
return { rows: F.STUDIO_SHIPS.filter((s) => new Date(s.shipped_at) > new Date(params[1])) };
|
|
53
|
+
}
|
|
54
|
+
if (/FROM credit_log/.test(q)) {
|
|
55
|
+
const prefix = String(params[1] || '').replace(/%$/, '').replace(/\\([\\%_])/g, '$1');
|
|
56
|
+
const rows = F.CREDIT_ROWS
|
|
57
|
+
.filter((r) => String(r.builder_id) === String(params[0]) && r.reason.startsWith(prefix))
|
|
58
|
+
.map((r) => ({ ...r }));
|
|
59
|
+
return { rows: rows.map((r, _, all) => ({ ...r, total_count: all.length })) };
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`unexpected SQL in the page reads: ${q.slice(0, 120)}`);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Sign-in is the one thing stubbed: the caller is whoever the header names.
|
|
66
|
+
api.requireBuilder = (req, res, next) => {
|
|
67
|
+
req.builder = { id: String(req.headers['x-builder'] || F.ALICE.id), rank: 'thetes' };
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const seams = require('../src/module-seams.js');
|
|
72
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
|
|
73
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
74
|
+
|
|
75
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
76
|
+
const express = require('express');
|
|
77
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
78
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
79
|
+
|
|
80
|
+
const app = express();
|
|
81
|
+
app.use(attachFail);
|
|
82
|
+
app.use(buildRouter());
|
|
83
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
84
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
85
|
+
|
|
86
|
+
async function get(p, builder) {
|
|
87
|
+
const r = await fetch(base + p, { headers: builder ? { 'x-builder': builder } : {} });
|
|
88
|
+
return { status: r.status, body: await r.json() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Three roots: both artifacts, the inventory alone, and nothing at all.
|
|
92
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reads-'));
|
|
93
|
+
const root = (name, files) => {
|
|
94
|
+
const dir = path.join(tmp, name);
|
|
95
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
96
|
+
for (const [rel, v] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), JSON.stringify(v));
|
|
97
|
+
return dir;
|
|
98
|
+
};
|
|
99
|
+
const FULL = root('full', { 'docs/page-inventory.json': F.INVENTORY, 'docs/page-readings.json': F.READINGS });
|
|
100
|
+
const INV_ONLY = root('inventory-only', { 'docs/page-inventory.json': F.INVENTORY });
|
|
101
|
+
const EMPTY = root('empty', {});
|
|
102
|
+
|
|
103
|
+
const { test, summary } = makeRunner();
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
pageData._setRoot(FULL);
|
|
107
|
+
|
|
108
|
+
await test('GET /copy-desk/pages: the three fixture cases, and the per-surface rollup', async () => {
|
|
109
|
+
const { status, body } = await get('/copy-desk/pages');
|
|
110
|
+
assert.equal(status, 200);
|
|
111
|
+
const p = (id) => body.pages.find((x) => x.id === id);
|
|
112
|
+
assert.equal(p('landing:index').status_label, 'Untweaked');
|
|
113
|
+
assert.equal(p('landing:projects').status_label, 'Text tweaked 2x');
|
|
114
|
+
assert.equal(p('landing:projects').drift.changed, false);
|
|
115
|
+
assert.equal(p('builders:studio').status_label, 'Text tweaked 1x');
|
|
116
|
+
assert.equal(p('builders:studio').drift.changed_count, 1);
|
|
117
|
+
assert.equal(p('builders:studio').open_round.state, 'writing');
|
|
118
|
+
assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 2 tweaked', '1 of 1 tweaked', '0 of 1 tweaked']);
|
|
119
|
+
assert.deepEqual(body.readings, { ok: true, at: 'docs/page-readings.json' });
|
|
120
|
+
assert.deepEqual(body.inventory, { at: 'docs/page-inventory.json' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('GET /copy-desk/pages/:pageId: changelog, changed-since lines and the ships that changed them', async () => {
|
|
124
|
+
seen.length = 0;
|
|
125
|
+
const { status, body } = await get('/copy-desk/pages/builders:studio');
|
|
126
|
+
assert.equal(status, 200);
|
|
127
|
+
assert.deepEqual(body.changelog.map((c) => [c.task_id, c.artist.login, c.line_count]), [['201', 'alice', 1]]);
|
|
128
|
+
assert.deepEqual(body.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
|
|
129
|
+
assert.deepEqual(body.drift.ships.map((s) => s.task_id), ['555']);
|
|
130
|
+
assert.deepEqual(body.open_round.holder, { id: '8', login: 'bob' });
|
|
131
|
+
// The ships read is asked about THIS page's files, since THIS page's last round.
|
|
132
|
+
const ships = seen.find((s) => /t\.touches &&/.test(s.q));
|
|
133
|
+
assert.deepEqual(ships.params[0], F.STUDIO_FILES);
|
|
134
|
+
assert.equal(ships.params[1], '2026-09-20T10:00:00.000Z');
|
|
135
|
+
|
|
136
|
+
const twice = await get('/copy-desk/pages/landing:projects');
|
|
137
|
+
assert.deepEqual(twice.body.changelog.map((c) => c.artist.login), ['bob', 'alice']);
|
|
138
|
+
const untweaked = await get('/copy-desk/pages/landing:index');
|
|
139
|
+
assert.deepEqual([untweaked.body.count, untweaked.body.changelog.length, untweaked.body.drift.ships.length], [0, 0, 0]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('an id the inventory does not know is unknown_page, never a blank page', async () => {
|
|
143
|
+
for (const id of ['landing:nope', 'not-an-id']) {
|
|
144
|
+
const { status, body } = await get(`/copy-desk/pages/${id}`);
|
|
145
|
+
assert.equal(status, 404, id);
|
|
146
|
+
assert.equal(body.error.code, 'unknown_page', id);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('GET /copy-desk/tally: the caller\'s own, through the reward port, 0 where no payout row exists yet', async () => {
|
|
151
|
+
seen.length = 0;
|
|
152
|
+
const alice = await get('/copy-desk/tally', F.ALICE.id);
|
|
153
|
+
assert.equal(alice.status, 200);
|
|
154
|
+
assert.equal(alice.body.credits, 60);
|
|
155
|
+
assert.equal(alice.body.pages_tweaked, 2);
|
|
156
|
+
assert.equal(alice.body.builder_id, '7');
|
|
157
|
+
assert.equal(alice.body.truncated, false);
|
|
158
|
+
const reads = seen.filter((s) => /FROM credit_log/.test(s.q));
|
|
159
|
+
assert.equal(reads.length, 1, 'one bounded ledger read, never a paging loop');
|
|
160
|
+
assert.equal(String(reads[0].params[0]), '7', 'the ledger is read for the CALLER');
|
|
161
|
+
const bob = await get('/copy-desk/tally', F.BOB.id);
|
|
162
|
+
assert.deepEqual([bob.body.credits, bob.body.this_week, bob.body.pages_tweaked], [0, 0, 1]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('no readings: status still reads from the ledger, and drift says it does not know', async () => {
|
|
166
|
+
pageData._setRoot(INV_ONLY);
|
|
167
|
+
const { status, body } = await get('/copy-desk/pages');
|
|
168
|
+
assert.equal(status, 200);
|
|
169
|
+
assert.equal(body.readings.ok, false);
|
|
170
|
+
assert.equal(body.readings.code, 'page_readings_missing');
|
|
171
|
+
const studio = body.pages.find((x) => x.id === 'builders:studio');
|
|
172
|
+
assert.equal(studio.status_label, 'Text tweaked 1x');
|
|
173
|
+
assert.deepEqual([studio.drift.known, studio.drift.reason], [false, 'readings_missing']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('no inventory: a named 503 with the command that fixes it, and the tally still answers', async () => {
|
|
177
|
+
pageData._setRoot(EMPTY);
|
|
178
|
+
for (const p of ['/copy-desk/pages', '/copy-desk/pages/landing:index']) {
|
|
179
|
+
const { status, body } = await get(p);
|
|
180
|
+
assert.equal(status, 503, p);
|
|
181
|
+
assert.equal(body.error.code, 'page_inventory_missing', p);
|
|
182
|
+
assert.equal(body.error.details.run, 'node scripts/gds/page-inventory.js', p);
|
|
183
|
+
}
|
|
184
|
+
const tally = await get('/copy-desk/tally', F.ALICE.id);
|
|
185
|
+
assert.equal(tally.status, 200);
|
|
186
|
+
assert.equal(tally.body.credits, 60);
|
|
187
|
+
});
|
|
188
|
+
} finally {
|
|
189
|
+
pageData._setRoot(null);
|
|
190
|
+
server.close();
|
|
191
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
summary();
|