@bongos/core 1.19.1058 → 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 +42 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +128 -2
- package/docs/api-reference.md +4 -2
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -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/pages.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +212 -14
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +47 -11
- package/modules/lifecycle/lifecycle.js +7 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -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
|
@@ -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();
|