@bongos/core 1.19.1060 → 1.19.1062
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 +62 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +294 -4
- package/docs/api-reference.md +7 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/pages.js +216 -0
- package/modules/copy-desk/routes/copy-desk.js +467 -0
- package/modules/copy-desk/tests/copy_docx.mjs +347 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +66 -2
- package/modules/copy-desk/tests/copy_page_draft.mjs +202 -0
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +131 -0
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/page-tweak-reads.js +11 -2
- package/modules/lifecycle/page-tweak-write.js +192 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_docx.mjs +279 -0
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fitness.mjs +7 -2
- package/tests/fixtures/page-ledger.mjs +33 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// tests/copy_desk_page_docx.mjs — download a page as .docx, edit it the way
|
|
2
|
+
// Word stores an edit, and upload it back into the draft, the WHOLE path (task
|
|
3
|
+
// 1004320 / BV2.TW09, ADR 0341 D12).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: a round trip with edits lands those edits in the draft, and a
|
|
6
|
+
// structure-changing upload is refused by name.
|
|
7
|
+
//
|
|
8
|
+
// Driven through the real copy-desk routes (with the real raw-body parser and
|
|
9
|
+
// its cap), the real lifecycle port (the page claim, updateHeldTaskDescription,
|
|
10
|
+
// submitPageTweak, createTask) and the real page reads, against the shared
|
|
11
|
+
// stateful fake ledger (tests/fixtures/page-ledger.mjs). The reading is a file
|
|
12
|
+
// on disk, read through the real page-data.js. The file is edited by the
|
|
13
|
+
// Word-shaped fixture, which re-packs it with its own zip writer.
|
|
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
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
22
|
+
import { resave, typeInto, wordRuns, lineParagraph } from '../modules/copy-desk/tests/fixtures/word-docx.mjs';
|
|
23
|
+
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
27
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
28
|
+
const docx = require('../modules/copy-desk/docx.js');
|
|
29
|
+
const api = require('../src/module-api.js');
|
|
30
|
+
|
|
31
|
+
const { db } = installPageLedger(api, {
|
|
32
|
+
builders: [
|
|
33
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
34
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
const ALICE = '7';
|
|
38
|
+
const BOB = '8';
|
|
39
|
+
|
|
40
|
+
api.requireBuilder = (req, res, next) => {
|
|
41
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
42
|
+
next();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const seams = require('../src/module-seams.js');
|
|
46
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
47
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
48
|
+
|
|
49
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
50
|
+
const express = require('express');
|
|
51
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
52
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
53
|
+
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
app.use(attachFail);
|
|
57
|
+
app.use(buildRouter());
|
|
58
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
59
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
60
|
+
|
|
61
|
+
async function call(method, p, builder, body = {}) {
|
|
62
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
63
|
+
return { status: r.status, body: await r.json() };
|
|
64
|
+
}
|
|
65
|
+
async function getDocx(p, builder) {
|
|
66
|
+
const r = await fetch(base + p, { headers: { 'x-builder': builder } });
|
|
67
|
+
return { status: r.status, headers: r.headers, buf: Buffer.from(await r.arrayBuffer()) };
|
|
68
|
+
}
|
|
69
|
+
async function putDocx(p, builder, buf, type = docx.DOCX_MIME) {
|
|
70
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': type, 'x-builder': builder }, body: buf });
|
|
71
|
+
return { status: r.status, body: await r.json() };
|
|
72
|
+
}
|
|
73
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
74
|
+
const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
75
|
+
|
|
76
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
77
|
+
const STUDIO_LINES = [
|
|
78
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
79
|
+
L('L0002', 'top bar', 'Ctrl K', 'shared'),
|
|
80
|
+
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
81
|
+
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
82
|
+
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
83
|
+
L('L0006', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
|
|
84
|
+
];
|
|
85
|
+
const INDEX_LINES = [
|
|
86
|
+
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
87
|
+
];
|
|
88
|
+
const readingsWith = (studioHash) => ({
|
|
89
|
+
schema: 1,
|
|
90
|
+
pages: [
|
|
91
|
+
{ id: 'builders:studio', reading_hash: studioHash, lines: STUDIO_LINES },
|
|
92
|
+
{ id: 'landing:index', reading_hash: 'index-h1', lines: INDEX_LINES },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-docx-'));
|
|
97
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
98
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
99
|
+
function writeReadings(r) {
|
|
100
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
|
|
101
|
+
pageData._setRoot(tmp); // drop the (mtime, size) cache
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { test, summary } = makeRunner();
|
|
105
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
106
|
+
const INDEX = '/copy-desk/pages/landing:index';
|
|
107
|
+
const studioTask = () => db.tasks.find((t) => t.source_ref === 'page-tweak/builders:studio/r1');
|
|
108
|
+
const draftLines = () => pages.parseDraftBlock(studioTask().description).draft.lines.map((l) => [l.key, l.before, l.after]);
|
|
109
|
+
let downloaded = null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
writeReadings(readingsWith('studio-h1'));
|
|
113
|
+
|
|
114
|
+
await test('the download works before anyone claims the page: the reading, as a Word file', async () => {
|
|
115
|
+
const r = await getDocx(`${INDEX}/draft.docx`, BOB);
|
|
116
|
+
assert.equal(r.status, 200);
|
|
117
|
+
assert.equal(r.headers.get('content-type'), docx.DOCX_MIME);
|
|
118
|
+
assert.equal(r.headers.get('content-disposition'), 'attachment; filename="tweak-landing-index.docx"');
|
|
119
|
+
const u = docx.readUpload(r.buf);
|
|
120
|
+
assert.equal(u.ok, true);
|
|
121
|
+
assert.equal(u.page_id, 'landing:index');
|
|
122
|
+
assert.deepEqual([...u.baseline], [['L0001', 'Build here']]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('the download is built from the reading plus the artist\'s current draft', async () => {
|
|
126
|
+
assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
|
|
127
|
+
const saved = await call('PUT', `${STUDIO}/draft`, ALICE, { reading_hash: 'studio-h1', lines: [{ key: 'L0003', after: 'Welcome home' }] });
|
|
128
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
129
|
+
const r = await getDocx(`${STUDIO}/draft.docx`, ALICE);
|
|
130
|
+
assert.equal(r.status, 200);
|
|
131
|
+
downloaded = r.buf;
|
|
132
|
+
const u = docx.readUpload(r.buf);
|
|
133
|
+
assert.deepEqual([u.page_id, u.reading_hash], ['builders:studio', 'studio-h1']);
|
|
134
|
+
assert.equal(u.baseline.get('L0003'), 'Welcome home', 'the draft rewrite, not the page text');
|
|
135
|
+
assert.equal(u.baseline.get('L0006'), 'Resume where you left off');
|
|
136
|
+
assert.deepEqual(u.paragraphs.filter((p) => p.tags.length).map((p) => p.tags[0]), STUDIO_LINES.map((l) => l.key));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await test('a round trip with edits (Word\'s split runs and smart quotes) lands those edits in the draft', async () => {
|
|
140
|
+
const writes = db.writes;
|
|
141
|
+
const edited = resave(downloaded, {
|
|
142
|
+
document: (xml) => typeInto(typeInto(xml, 'L0006', wordRuns(['Pick ', 'up where ', 'you’re ', 'at'])),
|
|
143
|
+
'L0004', wordRuns(['{…} ', 'pages rewritten'])),
|
|
144
|
+
});
|
|
145
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
|
|
146
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
147
|
+
assert.equal(r.body.outcome, 'saved');
|
|
148
|
+
assert.deepEqual(r.body.merge, { mode: 'tag', baseline: true, from_file: ['L0004', 'L0006'], kept_from_draft: ['L0003'] });
|
|
149
|
+
assert.deepEqual(r.body.counter, { changed: 3, total: 6 });
|
|
150
|
+
assert.deepEqual(draftLines(), [
|
|
151
|
+
['L0003', 'Welcome back', 'Welcome home'],
|
|
152
|
+
['L0004', '{…} pages tweaked', '{…} pages rewritten'],
|
|
153
|
+
['L0006', 'Resume where you left off', 'Pick up where you\'re at'],
|
|
154
|
+
]);
|
|
155
|
+
// Into the DRAFT and nowhere else: one write, the round still writing,
|
|
156
|
+
// no frozen batch, one draft block, and the reading on disk untouched.
|
|
157
|
+
assert.equal(db.writes, writes + 1);
|
|
158
|
+
assert.equal(studioTask().status, 'active');
|
|
159
|
+
assert.equal(pages.blockPresence(studioTask().description).batch, false);
|
|
160
|
+
assert.equal((studioTask().description.match(/```page-tweak-draft/g) || []).length, 1);
|
|
161
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'docs/page-readings.json'), 'utf8')), readingsWith('studio-h1'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('the same upload again writes nothing', async () => {
|
|
165
|
+
const writes = db.writes;
|
|
166
|
+
const stamp = studioTask().updated_at;
|
|
167
|
+
const edited = resave(downloaded, {
|
|
168
|
+
document: (xml) => typeInto(typeInto(xml, 'L0006', wordRuns(['Pick ', 'up where ', 'you’re ', 'at'])),
|
|
169
|
+
'L0004', wordRuns(['{…} ', 'pages rewritten'])),
|
|
170
|
+
});
|
|
171
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
|
|
172
|
+
assert.equal(r.body.outcome, 'unchanged');
|
|
173
|
+
assert.equal(db.writes, writes);
|
|
174
|
+
assert.equal(studioTask().updated_at, stamp);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('a rewrite made in the studio after the download survives an upload of the older file', async () => {
|
|
178
|
+
// The artist keeps working in the studio (a whole-draft save), then
|
|
179
|
+
// uploads the file they downloaded earlier with one more Word edit.
|
|
180
|
+
const saved = await call('PUT', `${STUDIO}/draft`, ALICE, { reading_hash: 'studio-h1', lines: [{ key: 'L0003', after: 'Hello again' }, { key: 'L0005', after: 'Nothing is waiting' }] });
|
|
181
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
182
|
+
const edited = resave(downloaded, { document: (xml) => typeInto(xml, 'L0001', wordRuns(['Your ', 'studio'])) });
|
|
183
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
|
|
184
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
185
|
+
assert.deepEqual(r.body.merge.from_file, ['L0001']);
|
|
186
|
+
assert.deepEqual(draftLines().map(([k, , a]) => [k, a]), [
|
|
187
|
+
['L0001', 'Your studio'],
|
|
188
|
+
['L0003', 'Hello again'],
|
|
189
|
+
['L0005', 'Nothing is waiting'],
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('a structure-changing upload is refused by name, and nothing is saved', async () => {
|
|
194
|
+
const writes = db.writes;
|
|
195
|
+
const buf = resave(downloaded, {
|
|
196
|
+
document: (xml) => {
|
|
197
|
+
const p = lineParagraph(xml, 'L0003');
|
|
198
|
+
return xml.replace(lineParagraph(xml, 'L0005'), '').replace(p, () => `${p}<w:p><w:r><w:t>An extra thought</w:t></w:r></w:p>`);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, buf);
|
|
202
|
+
assert.equal(r.status, 409);
|
|
203
|
+
assert.equal(errCode(r.body), 'structure_changed');
|
|
204
|
+
assert.deepEqual(errDetail(r.body).moved, [
|
|
205
|
+
{ change: 'added', key: null, section: 'greeting', first_words: 'An extra thought' },
|
|
206
|
+
{ change: 'dropped', key: 'L0005', section: 'greeting', first_words: 'Your desk is quiet today' },
|
|
207
|
+
]);
|
|
208
|
+
assert.match(errDetail(r.body).what_this_means, /added, removed or moved/);
|
|
209
|
+
assert.equal(db.writes, writes);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await test('another page\'s file is wrong_page; a file from an older reading is reading_moved', async () => {
|
|
213
|
+
assert.equal((await call('POST', `${INDEX}/claim`, BOB)).status, 201);
|
|
214
|
+
const wrong = await putDocx(`${INDEX}/draft.docx`, BOB, downloaded);
|
|
215
|
+
assert.equal(wrong.status, 409);
|
|
216
|
+
assert.equal(errCode(wrong.body), 'wrong_page');
|
|
217
|
+
assert.equal(errDetail(wrong.body).file_page_id, 'builders:studio');
|
|
218
|
+
|
|
219
|
+
writeReadings(readingsWith('studio-h2'));
|
|
220
|
+
const moved = await putDocx(`${STUDIO}/draft.docx`, ALICE, downloaded);
|
|
221
|
+
assert.equal(moved.status, 409);
|
|
222
|
+
assert.equal(errCode(moved.body), 'reading_moved');
|
|
223
|
+
assert.deepEqual([errDetail(moved.body).reading_hash, errDetail(moved.body).file_reading_hash], ['studio-h2', 'studio-h1']);
|
|
224
|
+
writeReadings(readingsWith('studio-h1'));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('only the holder may upload: page_not_held names the holder', async () => {
|
|
228
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, BOB, downloaded);
|
|
229
|
+
assert.equal(r.status, 409);
|
|
230
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
231
|
+
assert.deepEqual(errDetail(r.body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('the upload is user input: anything not a .docx is refused, and the size is capped', async () => {
|
|
235
|
+
const writes = db.writes;
|
|
236
|
+
const json = await call('POST', `${STUDIO}/draft.docx`, ALICE, { lines: [] });
|
|
237
|
+
assert.equal(json.status, 415);
|
|
238
|
+
assert.equal(errCode(json.body), 'not_a_docx');
|
|
239
|
+
const text = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.from('hello'), 'text/plain');
|
|
240
|
+
assert.equal(errCode(text.body), 'not_a_docx');
|
|
241
|
+
const junk = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.from('not a zip at all'));
|
|
242
|
+
assert.equal(junk.status, 415);
|
|
243
|
+
assert.equal(errCode(junk.body), 'not_a_docx');
|
|
244
|
+
const zip = await putDocx(`${STUDIO}/draft.docx`, ALICE, docx.writeZip([{ name: 'notes.txt', data: 'hi' }]), 'application/zip');
|
|
245
|
+
assert.equal(errCode(zip.body), 'not_a_docx');
|
|
246
|
+
const big = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.alloc(docx.MAX_UPLOAD_BYTES + 1024, 7));
|
|
247
|
+
assert.equal(big.status, 413);
|
|
248
|
+
assert.equal(errCode(big.body), 'upload_too_large');
|
|
249
|
+
assert.equal(db.writes, writes);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('no round is no_open_round; after submit the draft is frozen, so an upload is page_in_flight', async () => {
|
|
253
|
+
const indexFile = (await getDocx(`${INDEX}/draft.docx`, BOB)).buf;
|
|
254
|
+
// Bob holds index; a page nobody opened a round on has nowhere to land.
|
|
255
|
+
const inv = F.INVENTORY.pages.find((p) => p.id !== 'builders:studio' && p.id !== 'landing:index');
|
|
256
|
+
assert.ok(inv, 'the fixture inventory has a page with no round');
|
|
257
|
+
const readings = readingsWith('studio-h1');
|
|
258
|
+
readings.pages.push({ id: inv.id, reading_hash: 'x-h1', lines: [L('L0001', 'hero', 'Hi', 'placed', { file: 'f', line: 1, string_id: 's' })] });
|
|
259
|
+
writeReadings(readings);
|
|
260
|
+
const other = (await getDocx(`/copy-desk/pages/${inv.id}/draft.docx`, BOB)).buf;
|
|
261
|
+
const none = await putDocx(`/copy-desk/pages/${inv.id}/draft.docx`, BOB, other);
|
|
262
|
+
assert.equal(errCode(none.body), 'no_open_round');
|
|
263
|
+
writeReadings(readingsWith('studio-h1'));
|
|
264
|
+
const edited = resave(indexFile, { document: (xml) => typeInto(xml, 'L0001', wordRuns(['Build ', 'it here'])) });
|
|
265
|
+
assert.equal((await putDocx(`${INDEX}/draft.docx`, BOB, edited)).body.outcome, 'saved');
|
|
266
|
+
assert.equal((await call('POST', `${INDEX}/submit`, BOB)).body.outcome, 'submitted');
|
|
267
|
+
const after = await putDocx(`${INDEX}/draft.docx`, BOB, edited);
|
|
268
|
+
assert.equal(after.status, 409);
|
|
269
|
+
assert.equal(errCode(after.body), 'page_in_flight');
|
|
270
|
+
// The submitted batch carries the Word edit, with its target from the reading.
|
|
271
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
272
|
+
assert.deepEqual(pages.parseBatchBlock(t.description).batch.lines.map((l) => [l.key, l.file, l.before, l.after]),
|
|
273
|
+
[['L0001', 'modules/public-landing/public/index.html', 'Build here', 'Build it here']]);
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
server.close();
|
|
277
|
+
pageData._setRoot(null);
|
|
278
|
+
}
|
|
279
|
+
summary();
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tests/copy_desk_page_submit.mjs — save a page draft, submit it, and split the
|
|
2
|
+
// unplaced lines out, the WHOLE path (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: draft save, submit and the unplaced split work end to end.
|
|
5
|
+
//
|
|
6
|
+
// Driven through the real copy-desk routes, the real lifecycle port (the page
|
|
7
|
+
// claim, updateHeldTaskDescription, submitPageTweak, and the real createTask
|
|
8
|
+
// under it) and the real page reads, against the shared stateful fake ledger
|
|
9
|
+
// (tests/fixtures/page-ledger.mjs), whose page lock and task row lock are real
|
|
10
|
+
// mutexes. The reading is a file on disk, read through the real page-data.js.
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { makeRunner } from './helpers.mjs';
|
|
18
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
19
|
+
|
|
20
|
+
process.env.NODE_ENV = 'test';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
23
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
24
|
+
const api = require('../src/module-api.js');
|
|
25
|
+
|
|
26
|
+
const { db, seen } = installPageLedger(api, {
|
|
27
|
+
builders: [
|
|
28
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
29
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
const ALICE = '7';
|
|
33
|
+
const BOB = '8';
|
|
34
|
+
|
|
35
|
+
api.requireBuilder = (req, res, next) => {
|
|
36
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
37
|
+
next();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const seams = require('../src/module-seams.js');
|
|
41
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
42
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
43
|
+
|
|
44
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
45
|
+
const express = require('express');
|
|
46
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
47
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
48
|
+
|
|
49
|
+
const app = express();
|
|
50
|
+
app.use(express.json());
|
|
51
|
+
app.use(attachFail);
|
|
52
|
+
app.use(buildRouter());
|
|
53
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
54
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
55
|
+
|
|
56
|
+
async function call(method, p, builder, body = {}) {
|
|
57
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
58
|
+
return { status: r.status, body: await r.json() };
|
|
59
|
+
}
|
|
60
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
61
|
+
const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
62
|
+
|
|
63
|
+
// The studio reading, in page-reader.js's shape: a shared line with its
|
|
64
|
+
// registry row, a shared line without one, placed lines, one with {…} holes,
|
|
65
|
+
// and an unplaced line.
|
|
66
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
67
|
+
const STUDIO_LINES = [
|
|
68
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
69
|
+
L('L0002', 'top bar', 'Ctrl K', 'shared'),
|
|
70
|
+
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
71
|
+
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
72
|
+
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
73
|
+
];
|
|
74
|
+
const INDEX_LINES = [
|
|
75
|
+
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
76
|
+
L('L0002', 'hero', 'Start a project', 'placed', { file: 'modules/public-landing/public/index.html', line: 4, string_id: 'ix0002' }),
|
|
77
|
+
];
|
|
78
|
+
const readingsWith = (studio, index) => ({
|
|
79
|
+
schema: 1,
|
|
80
|
+
pages: [
|
|
81
|
+
{ id: 'builders:studio', reading_hash: studio.hash, lines: studio.lines },
|
|
82
|
+
{ id: 'landing:index', reading_hash: index.hash, lines: index.lines },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-submit-'));
|
|
87
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
89
|
+
function writeReadings(r) {
|
|
90
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
|
|
91
|
+
pageData._setRoot(tmp); // drop the (mtime, size) cache
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { test, summary } = makeRunner();
|
|
95
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
96
|
+
const INDEX = '/copy-desk/pages/landing:index';
|
|
97
|
+
const studioTask = () => db.tasks.find((t) => t.source_ref === 'page-tweak/builders:studio/r1');
|
|
98
|
+
const DRAFT = {
|
|
99
|
+
reading_hash: 'studio-h1',
|
|
100
|
+
lines: [
|
|
101
|
+
{ key: 'L0003', after: 'Welcome home' },
|
|
102
|
+
{ key: 'L0004', after: '{…} pages rewritten so far' },
|
|
103
|
+
{ key: 'L0005', after: 'Nothing is waiting for you' },
|
|
104
|
+
{ key: 'L0002', after: 'Control K' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
writeReadings(readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h1', lines: INDEX_LINES }));
|
|
110
|
+
|
|
111
|
+
await test('no round yet: a draft save is no_open_round, and nothing is written', async () => {
|
|
112
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
113
|
+
assert.equal(r.status, 409);
|
|
114
|
+
assert.equal(errCode(r.body), 'no_open_round');
|
|
115
|
+
assert.equal(db.tasks.length, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('the holder saves a draft: it rests on the round task, resolved from the reading', async () => {
|
|
119
|
+
assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
|
|
120
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
121
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
122
|
+
assert.equal(r.body.outcome, 'saved');
|
|
123
|
+
assert.deepEqual(r.body.counter, { changed: 4, total: 5 });
|
|
124
|
+
const parsed = pages.parseDraftBlock(studioTask().description);
|
|
125
|
+
assert.equal(parsed.ok, true);
|
|
126
|
+
assert.deepEqual(parsed.draft.lines.map((l) => [l.key, l.before, l.after, l.placement]), [
|
|
127
|
+
['L0002', 'Ctrl K', 'Control K', 'shared'],
|
|
128
|
+
['L0003', 'Welcome back', 'Welcome home', 'placed'],
|
|
129
|
+
['L0004', '{…} pages tweaked', '{…} pages rewritten so far', 'placed'],
|
|
130
|
+
['L0005', 'Your desk is quiet today', 'Nothing is waiting for you', 'unplaced'],
|
|
131
|
+
]);
|
|
132
|
+
assert.match(studioTask().description, /^Page tweak round 1/, 'the round body stays; the block is added below it');
|
|
133
|
+
assert.equal(studioTask().status, 'active');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test('the same autosave again writes nothing: unchanged, and the task is not touched', async () => {
|
|
137
|
+
const writes = db.writes;
|
|
138
|
+
const stamp = studioTask().updated_at;
|
|
139
|
+
const before = seen.length;
|
|
140
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
141
|
+
assert.equal(r.status, 200);
|
|
142
|
+
assert.equal(r.body.outcome, 'unchanged');
|
|
143
|
+
assert.equal(db.writes, writes);
|
|
144
|
+
assert.equal(studioTask().updated_at, stamp);
|
|
145
|
+
assert.equal(seen.slice(before).some((q) => /^UPDATE tasks/.test(q)), false);
|
|
146
|
+
// The save reads this page's rounds only, never the whole instance's.
|
|
147
|
+
assert.equal(db.pageScopes[db.pageScopes.length - 1], 'page-tweak/builders:studio/');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('a whole-draft replace: a line left out of the next save leaves the draft', async () => {
|
|
151
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, lines: DRAFT.lines.slice(0, 3) });
|
|
152
|
+
assert.equal(r.body.outcome, 'saved');
|
|
153
|
+
assert.deepEqual(pages.parseDraftBlock(studioTask().description).draft.lines.map((l) => l.key), ['L0003', 'L0004', 'L0005']);
|
|
154
|
+
const back = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
155
|
+
assert.equal(back.body.outcome, 'saved');
|
|
156
|
+
assert.equal((studioTask().description.match(/```page-tweak-draft/g) || []).length, 1, 'replaced in place, never a second block');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await test('another artist cannot save it: page_not_held, naming the holder', async () => {
|
|
160
|
+
const r = await call('PUT', `${STUDIO}/draft`, BOB, DRAFT);
|
|
161
|
+
assert.equal(r.status, 409);
|
|
162
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
163
|
+
assert.deepEqual(errDetail(r.body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('a draft built on an older reading is refused reading_moved, and nothing is written', async () => {
|
|
167
|
+
const writes = db.writes;
|
|
168
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, reading_hash: 'studio-h0' });
|
|
169
|
+
assert.equal(r.status, 409);
|
|
170
|
+
assert.equal(errCode(r.body), 'reading_moved');
|
|
171
|
+
assert.equal(errDetail(r.body).reading_hash, 'studio-h1');
|
|
172
|
+
assert.equal(db.writes, writes);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('the body is strict: a target field at the top level is refused', async () => {
|
|
176
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, file: 'modules/hall-ui/public/studio.html' });
|
|
177
|
+
assert.equal(errCode(r.body), 'validation_failed');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('another artist cannot submit it', async () => {
|
|
181
|
+
const r = await call('POST', `${STUDIO}/submit`, BOB);
|
|
182
|
+
assert.equal(r.status, 409);
|
|
183
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
184
|
+
assert.equal(pages.blockPresence(studioTask().description).batch, false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('submit freezes the batch, files an engineer task per unplaced line, and releases the claim', async () => {
|
|
188
|
+
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
189
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
190
|
+
assert.equal(r.body.outcome, 'submitted');
|
|
191
|
+
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'submitted', status: 'ready' });
|
|
192
|
+
assert.equal(r.body.batch.line_count, 2);
|
|
193
|
+
assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
|
|
194
|
+
|
|
195
|
+
// The batch: placed lines with their registry rows, from the reading.
|
|
196
|
+
const b = pages.parseBatchBlock(studioTask().description);
|
|
197
|
+
assert.equal(b.ok, true);
|
|
198
|
+
assert.deepEqual(b.batch.lines.map((l) => [l.key, l.file, l.line, l.string_id, l.before, l.after]), [
|
|
199
|
+
['L0003', 'modules/hall-ui/public/studio.html', 12, 'st0003', 'Welcome back', 'Welcome home'],
|
|
200
|
+
['L0004', 'modules/hall-ui/public/studio.js', 88, 'st0004', '{…} pages tweaked', '{…} pages rewritten so far'],
|
|
201
|
+
]);
|
|
202
|
+
assert.equal(b.batch.reading_hash, 'studio-h1');
|
|
203
|
+
|
|
204
|
+
// The unplaced split: the unplaced line, and the shared line with no row.
|
|
205
|
+
const eng = db.tasks.filter((t) => t.source === 'page-tweak-unplaced');
|
|
206
|
+
assert.deepEqual(eng.map((t) => t.source_ref).sort(), [
|
|
207
|
+
`page-tweak-unplaced/${studioTask().id}/L0002`,
|
|
208
|
+
`page-tweak-unplaced/${studioTask().id}/L0005`,
|
|
209
|
+
]);
|
|
210
|
+
for (const t of eng) {
|
|
211
|
+
assert.deepEqual([t.status, t.discipline, t.kind, t.version_id, t.goal_id, t.created_by], ['backlog', 'engineer', 'feature', 'V9', '77', ALICE]);
|
|
212
|
+
assert.match(t.description, /builders:studio/);
|
|
213
|
+
}
|
|
214
|
+
const quiet = eng.find((t) => t.source_ref.endsWith('/L0005'));
|
|
215
|
+
assert.match(quiet.description, /"Your desk is quiet today"/);
|
|
216
|
+
assert.match(quiet.description, /"Nothing is waiting for you"/);
|
|
217
|
+
assert.deepEqual([...b.batch.unplaced_tasks].sort(), eng.map((t) => t.id).sort(), 'the batch names the engineer tasks');
|
|
218
|
+
assert.deepEqual(r.body.unplaced_tasks.map((u) => u.created), [true, true]);
|
|
219
|
+
|
|
220
|
+
// The claim is released and the round is ready: the /tweak queue.
|
|
221
|
+
assert.equal(studioTask().status, 'ready');
|
|
222
|
+
const claim = db.claims.find((c) => c.task_id === studioTask().id);
|
|
223
|
+
assert.ok(claim.released_at, 'the web claim is released');
|
|
224
|
+
assert.equal(claim.outcome, 'partial');
|
|
225
|
+
// The draft stays beside the batch (ADR 0341 D7: it may be amended later).
|
|
226
|
+
assert.equal(pages.parseDraftBlock(studioTask().description).ok, true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('the page reads submitted, and nobody can claim it back into writing', async () => {
|
|
230
|
+
const page = await call('GET', STUDIO, BOB);
|
|
231
|
+
assert.equal(page.status, 200);
|
|
232
|
+
assert.equal(page.body.open_round.state, 'submitted');
|
|
233
|
+
const claim = await call('POST', `${STUDIO}/claim`, BOB);
|
|
234
|
+
assert.equal(errCode(claim.body), 'page_in_flight');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('a repeated submit is already_submitted: no second batch, no second engineer task', async () => {
|
|
238
|
+
const tasks = db.tasks.length;
|
|
239
|
+
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
240
|
+
assert.equal(r.status, 200);
|
|
241
|
+
assert.equal(r.body.outcome, 'already_submitted');
|
|
242
|
+
assert.equal(r.body.unplaced_tasks.length, 2);
|
|
243
|
+
assert.equal(db.tasks.length, tasks);
|
|
244
|
+
assert.equal((studioTask().description.match(/```page-tweak\n/g) || []).length, 1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('after submit the draft is frozen: a save is page_in_flight', async () => {
|
|
248
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
249
|
+
assert.equal(r.status, 409);
|
|
250
|
+
assert.equal(errCode(r.body), 'page_in_flight');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await test('a line whose reading moved is refused target_gone at submit; the round stays with its artist', async () => {
|
|
254
|
+
assert.equal((await call('POST', `${INDEX}/claim`, BOB)).status, 201);
|
|
255
|
+
const saved = await call('PUT', `${INDEX}/draft`, BOB, { reading_hash: 'index-h1', lines: [{ key: 'L0001', after: 'Build it here' }, { key: 'L0002', after: 'Start yours' }] });
|
|
256
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
257
|
+
// A ship rewords the hero line: the page no longer shows "Build here".
|
|
258
|
+
writeReadings(readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, {
|
|
259
|
+
hash: 'index-h2',
|
|
260
|
+
lines: [L('L0001', 'hero', 'Build anything here', 'placed', { file: 'x', line: 3, string_id: 'ix0009' }), INDEX_LINES[1]],
|
|
261
|
+
}));
|
|
262
|
+
const writes = db.writes;
|
|
263
|
+
const r = await call('POST', `${INDEX}/submit`, BOB);
|
|
264
|
+
assert.equal(r.status, 409);
|
|
265
|
+
assert.equal(errCode(r.body), 'lines_refused');
|
|
266
|
+
assert.deepEqual(errDetail(r.body).refused, [{ key: 'L0001', section: 'hero', before: 'Build here', code: 'target_gone' }]);
|
|
267
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
268
|
+
assert.equal(t.status, 'active');
|
|
269
|
+
assert.equal(pages.blockPresence(t.description).batch, false);
|
|
270
|
+
assert.equal(db.writes, writes);
|
|
271
|
+
assert.ok(db.claims.find((c) => c.task_id === t.id && c.released_at == null), 'the claim is still held');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await test('two submits at once: one freezes, the other is the same double-click, and one engineer task per line', async () => {
|
|
275
|
+
// Bob drops the gone line on the current reading, adds an unplaced-free
|
|
276
|
+
// rewrite, and double-submits.
|
|
277
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
278
|
+
const saved = await call('PUT', `${INDEX}/draft`, BOB, { reading_hash: 'index-h2', lines: [{ key: 'L0002', after: 'Start yours' }] });
|
|
279
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
280
|
+
const [a, b] = await Promise.all([call('POST', `${INDEX}/submit`, BOB), call('POST', `${INDEX}/submit`, BOB)]);
|
|
281
|
+
assert.deepEqual([a.body.outcome, b.body.outcome].sort(), ['already_submitted', 'submitted'], JSON.stringify([a.body, b.body]));
|
|
282
|
+
assert.equal((t.description.match(/```page-tweak\n/g) || []).length, 1);
|
|
283
|
+
assert.equal(t.status, 'ready');
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
server.close();
|
|
287
|
+
pageData._setRoot(null);
|
|
288
|
+
}
|
|
289
|
+
summary();
|
package/tests/fitness.mjs
CHANGED
|
@@ -922,9 +922,14 @@ test('write-route validation check actually CATCHES an unvalidated write route (
|
|
|
922
922
|
assert.ok(/POST \/widgets/.test(flagged[0]), 'the offending route is named');
|
|
923
923
|
});
|
|
924
924
|
|
|
925
|
-
test('write-route validation exemptions are the known dynamic-key prefs routes
|
|
925
|
+
test('write-route validation exemptions are the known dynamic-key prefs routes, plus the one raw-file upload', () => {
|
|
926
|
+
// The .docx upload (task 1004320) has no JSON body to hold a field schema: its
|
|
927
|
+
// body is the file's bytes, capped by the route's parser and read by
|
|
928
|
+
// docx.readUpload. Named here so a second raw-body exemption is a reviewed diff.
|
|
929
|
+
const RAW_FILE_UPLOADS = ['POST /copy-desk/pages/:pageId/draft.docx'];
|
|
926
930
|
const exempt = [...fitness.WRITE_VALIDATION_EXEMPT];
|
|
927
|
-
assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/')
|
|
931
|
+
assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/') || RAW_FILE_UPLOADS.includes(e)),
|
|
932
|
+
'exemptions are all /me/* prefs patches or a named raw-file upload: ' + exempt.join(', '));
|
|
928
933
|
});
|
|
929
934
|
|
|
930
935
|
test('identity gate strips comments on CRLF checkouts too (task 1002428)', () => {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// An unknown statement THROWS: a route that sends SQL this ledger does not
|
|
12
12
|
// model fails loudly instead of reading an empty answer.
|
|
13
13
|
//
|
|
14
|
-
// Shared by tests/copy_desk_page_claim.mjs
|
|
14
|
+
// Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs and
|
|
15
|
+
// tests/copy_desk_page_submit.mjs
|
|
15
16
|
// (a fixture, not a test: the runner discovers tests/*.mjs only).
|
|
16
17
|
|
|
17
18
|
import { strict as assert } from 'node:assert';
|
|
@@ -120,10 +121,40 @@ export function installPageLedger(api, { builders }) {
|
|
|
120
121
|
if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
|
|
121
122
|
return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
|
|
122
123
|
}
|
|
124
|
+
// The draft save and the submit (task 1004319 / TW08,
|
|
125
|
+
// modules/lifecycle/page-tweak-write.js). The task row lock is a real
|
|
126
|
+
// mutex too, so a raced second submit waits for the first to commit.
|
|
127
|
+
if (/^SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches FROM tasks WHERE id = \$1 FOR UPDATE$/.test(q)) {
|
|
128
|
+
assert.ok(owner, 'the task row must be locked on the transaction client');
|
|
129
|
+
await lock(`task:${p[0]}`, owner);
|
|
130
|
+
const t = db.tasks.find((x) => String(x.id) === String(p[0]));
|
|
131
|
+
return { rows: t ? [{ ...t }] : [] };
|
|
132
|
+
}
|
|
133
|
+
if (/^UPDATE tasks SET description = \$2, (status = 'ready', )?updated_at = now\(\) WHERE id = \$1 RETURNING id, status, description, source_ref, updated_at$/.test(q)) {
|
|
134
|
+
assert.ok(owner, 'a description write runs inside the transaction');
|
|
135
|
+
const t = db.tasks.find((x) => String(x.id) === String(p[0]));
|
|
136
|
+
t.description = p[1];
|
|
137
|
+
if (/status = 'ready'/.test(q)) t.status = 'ready';
|
|
138
|
+
t.updated_at = new Date().toISOString();
|
|
139
|
+
db.writes = (db.writes || 0) + 1;
|
|
140
|
+
return { rows: [{ id: t.id, status: t.status, description: t.description, source_ref: t.source_ref, updated_at: t.updated_at }] };
|
|
141
|
+
}
|
|
142
|
+
if (/^SELECT id, source_ref FROM tasks WHERE source = \$1 AND source_ref = ANY\(\$2::text\[\]\)$/.test(q)) {
|
|
143
|
+
return { rows: db.tasks.filter((t) => t.source === p[0] && p[1].includes(t.source_ref)).map((t) => ({ id: t.id, source_ref: t.source_ref })) };
|
|
144
|
+
}
|
|
145
|
+
if (/^UPDATE claims SET released_at = now\(\), outcome = \$2 WHERE id = \$1 AND released_at IS NULL$/.test(q)) {
|
|
146
|
+
const c = db.claims.find((x) => String(x.id) === String(p[0]) && x.released_at == null);
|
|
147
|
+
if (c) Object.assign(c, { released_at: new Date().toISOString(), outcome: p[1] });
|
|
148
|
+
return { rows: [] };
|
|
149
|
+
}
|
|
123
150
|
// The page reads (listPageTweakTasks), computed from the ledger's state.
|
|
124
151
|
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
125
152
|
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
126
|
-
|
|
153
|
+
// The optional page scope (task 1004319): $1 is the round-ref prefix or null.
|
|
154
|
+
assert.match(q, /left\(t\.source_ref, length\(\$1::text\)\) = \$1::text/);
|
|
155
|
+
const prefix = p[0] == null ? null : String(p[0]);
|
|
156
|
+
(db.pageScopes = db.pageScopes || []).push(prefix);
|
|
157
|
+
return { rows: db.tasks.filter((t) => t.source === 'page-tweak' && (prefix == null || String(t.source_ref).startsWith(prefix))).map(pageTweakRow) };
|
|
127
158
|
}
|
|
128
159
|
// The flag SQL (the page ask).
|
|
129
160
|
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)) {
|