@bongos/core 1.19.671 → 1.19.672
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 +31 -21
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +9 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +146 -143
- package/docs/copy-registry.json +230 -201
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/provisioning.js +13 -1
- package/modules/provisioning/routes/body-validators.js +94 -0
- package/modules/provisioning/routes/provisioning.js +31 -55
- package/modules/public-landing/public/projects.html +111 -1
- package/modules/public-landing/public/projects.states.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/project_type_editable.mjs +427 -0
- package/tests/projects_hub_pre_uat.mjs +7 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// tests/project_type_editable.mjs
|
|
2
|
+
//
|
|
3
|
+
// A declared project type can be CHANGED after creation (task 1003504, the
|
|
4
|
+
// task 1002772 follow-up).
|
|
5
|
+
//
|
|
6
|
+
// Task 1002772 shipped the Type step as a declaration and had to narrow the
|
|
7
|
+
// wizard's own promise to match the machinery: the copy stopped saying "you can
|
|
8
|
+
// change it later", because nothing owner-facing could. Its review panel named
|
|
9
|
+
// the three gaps (C4/C7/C8) — a create re-request answers `type_ignored`,
|
|
10
|
+
// `updateInstanceDetail`'s patch excluded `type`, and the curation POST
|
|
11
|
+
// /projects is Metic+ only, so an owner had no route at all. This closes the
|
|
12
|
+
// middle one and puts a picker on it, which is what earns the sentence back.
|
|
13
|
+
//
|
|
14
|
+
// ONE PREMISE CORRECTION, recorded because the task body carries the other
|
|
15
|
+
// reading: the task asks for "a type COALESCE-carry rule decision for the
|
|
16
|
+
// catalog". There is nothing to decide. The catalog upsert carries the type as
|
|
17
|
+
// COALESCE(EXCLUDED.type, existing.type) — preserve-on-null — and 'not-sure' is
|
|
18
|
+
// a REAL value in PROJECT_TYPES, so an owner's edit is always non-null and
|
|
19
|
+
// always wins. The carry rule only matters for a null, which is exactly why the
|
|
20
|
+
// route refuses one: a null would leave the map showing the declaration the
|
|
21
|
+
// owner had just taken back.
|
|
22
|
+
//
|
|
23
|
+
// Two layers, mirroring tests/provisioning_module_selection.mjs beside it:
|
|
24
|
+
// * PATCH /instances/:id/detail — validation (loud, never a silent drop),
|
|
25
|
+
// threading into the patch, and the receipt
|
|
26
|
+
// * the manage page's "Its kind" band — sliced out of the inline script and
|
|
27
|
+
// run in a vm, the tests/project_modules_ui.mjs idiom
|
|
28
|
+
//
|
|
29
|
+
// DB-free: the pool is a recording fake and the data-access fns the route needs
|
|
30
|
+
// are stubbed on the cached module object. No network beyond a loopback listener.
|
|
31
|
+
//
|
|
32
|
+
// Run: node --test --test-reporter=tap tests/project_type_editable.mjs
|
|
33
|
+
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import { after, test } from 'node:test';
|
|
37
|
+
import http from 'node:http';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import vm from 'node:vm';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
45
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
46
|
+
const catalogBridge = require('../modules/provisioning/catalog-bridge.js');
|
|
47
|
+
// Captured BEFORE layer 2 stubs the property. `node --test` defers every test body,
|
|
48
|
+
// so the stubbing below has already run by the time the first one executes — a layer-1
|
|
49
|
+
// test that reached for provisioning.updateInstanceDetail would silently exercise the
|
|
50
|
+
// fake and record nothing.
|
|
51
|
+
const realUpdateInstanceDetail = provisioning.updateInstanceDetail;
|
|
52
|
+
|
|
53
|
+
// ── layer 1 · the column write ───────────────────────────────────────────────
|
|
54
|
+
// A recording pool: assert the STATEMENT, because the difference between
|
|
55
|
+
// assigning the type and COALESCEing it is invisible in the returned row and is
|
|
56
|
+
// the whole point — this is the owner RE-declaring, so the new word has to win
|
|
57
|
+
// even over one curation put there.
|
|
58
|
+
function recordingDb(returnRow = {}) {
|
|
59
|
+
const calls = [];
|
|
60
|
+
return {
|
|
61
|
+
calls,
|
|
62
|
+
async query(text, params) { calls.push({ text, params }); return { rows: [returnRow] }; },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('updateInstanceDetail ASSIGNS the type — a re-declaration must win, not merge', async () => {
|
|
67
|
+
const db = recordingDb({ id: 41, type: 'research' });
|
|
68
|
+
await realUpdateInstanceDetail(db, 41, { type: 'research' });
|
|
69
|
+
assert.equal(db.calls.length, 1);
|
|
70
|
+
const { text, params } = db.calls[0];
|
|
71
|
+
assert.match(text, /type = \$\d+/, 'the type is set');
|
|
72
|
+
assert.doesNotMatch(text, /COALESCE/, 'never COALESCEd here — that rule belongs to the catalog projection, not the row');
|
|
73
|
+
assert.ok(params.includes('research'), 'the word reaches the statement');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('updateInstanceDetail leaves the column alone when no type is sent', async () => {
|
|
77
|
+
const db = recordingDb({ id: 41 });
|
|
78
|
+
await realUpdateInstanceDetail(db, 41, { description: 'A study of small things.' });
|
|
79
|
+
assert.doesNotMatch(db.calls[0].text, /type =/, 'a description-only patch must never touch the declaration');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('updateInstanceDetail can change the type and something else in one statement', async () => {
|
|
83
|
+
const db = recordingDb({ id: 41 });
|
|
84
|
+
await realUpdateInstanceDetail(db, 41, { description: 'Hello.', type: 'business' });
|
|
85
|
+
const { text, params } = db.calls[0];
|
|
86
|
+
assert.match(text, /description = \$\d+/);
|
|
87
|
+
assert.match(text, /type = \$\d+/);
|
|
88
|
+
assert.ok(params.includes('Hello.') && params.includes('business'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// EVERY branch of this statement, not just the one this task added. The type branch
|
|
92
|
+
// was written by editing the lines around the `modules` branch, and that edit dropped
|
|
93
|
+
// the `$` sigil from `modules = $${params.length}::jsonb` — leaving `modules = 3::jsonb`,
|
|
94
|
+
// which is not valid Postgres and would have failed every extras save on the very route
|
|
95
|
+
// this task's own field rides. The first cut of this file patched only `type` and
|
|
96
|
+
// `description`, so nothing here saw it; the grader did. Assert the SHAPE of the whole
|
|
97
|
+
// SET clause instead of one branch at a time, and no future branch can repeat it.
|
|
98
|
+
test('every SET clause binds a PLACEHOLDER — a bare index is not valid SQL', async () => {
|
|
99
|
+
const db = recordingDb({ id: 41 });
|
|
100
|
+
await realUpdateInstanceDetail(db, 41, {
|
|
101
|
+
description: 'Hello.', detail: { team_shape: 'solo' }, modules: ['dev-box'], type: 'business',
|
|
102
|
+
});
|
|
103
|
+
const { text, params } = db.calls[0];
|
|
104
|
+
const sets = text.slice(text.indexOf('SET '), text.indexOf(' WHERE '));
|
|
105
|
+
assert.doesNotMatch(sets, /=\s*\d/, `a column is being set to a literal number: ${sets}`);
|
|
106
|
+
for (const col of ['description', 'detail', 'modules', 'type']) {
|
|
107
|
+
assert.match(sets, new RegExp(`${col} = [^,]*\\$\\d`), `${col} binds a parameter`);
|
|
108
|
+
}
|
|
109
|
+
// and every parameter the builder pushed is actually referenced by the statement
|
|
110
|
+
for (let i = 2; i <= params.length; i++) {
|
|
111
|
+
assert.match(text, new RegExp(`\\$${i}\\b`), `$${i} is referenced — a pushed param nothing reads is a dropped write`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the modules branch still binds its JSON, not its own parameter index', async () => {
|
|
116
|
+
const db = recordingDb({ id: 41 });
|
|
117
|
+
await realUpdateInstanceDetail(db, 41, { modules: ['dev-box', 'discord'] });
|
|
118
|
+
const { text, params } = db.calls[0];
|
|
119
|
+
assert.match(text, /modules = \$\d+::jsonb/, 'the placeholder carries the cast, the index does not replace it');
|
|
120
|
+
assert.ok(params.some((p) => typeof p === 'string' && p.includes('dev-box')),
|
|
121
|
+
'the serialized selection reaches the statement');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── layer 2 · the route ──────────────────────────────────────────────────────
|
|
125
|
+
let detailRow = null;
|
|
126
|
+
let lastPatch = null;
|
|
127
|
+
let lastPublished = null;
|
|
128
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
129
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
130
|
+
provisioning.getInstanceById = async () => detailRow;
|
|
131
|
+
provisioning.recordEvent = async () => {};
|
|
132
|
+
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
133
|
+
lastPatch = patch;
|
|
134
|
+
return { ...detailRow, ...(patch.type !== undefined ? { type: patch.type } : {}),
|
|
135
|
+
...(patch.description !== undefined ? { description: patch.description } : {}) };
|
|
136
|
+
};
|
|
137
|
+
// The projection is where a change has to REACH — a type an owner changed that
|
|
138
|
+
// never leaves the platform row is a map telling strangers the old word.
|
|
139
|
+
catalogBridge.publishInstanceToCatalog = async (_db, row) => { lastPublished = row; return { published: true }; };
|
|
140
|
+
|
|
141
|
+
const api = require('../src/module-api.js');
|
|
142
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
143
|
+
|
|
144
|
+
const express = require('express');
|
|
145
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
146
|
+
const app = express();
|
|
147
|
+
app.use(express.json());
|
|
148
|
+
app.use((_req, res, next) => {
|
|
149
|
+
res.fail = (code, opts, details) => {
|
|
150
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
151
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
152
|
+
};
|
|
153
|
+
next();
|
|
154
|
+
});
|
|
155
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
156
|
+
const server = app.listen(0);
|
|
157
|
+
await new Promise((r) => server.once('listening', r));
|
|
158
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
159
|
+
|
|
160
|
+
function patchDetail(body) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const payload = JSON.stringify(body);
|
|
163
|
+
const req = http.request(`${base}/provisioning/instances/41/detail`, {
|
|
164
|
+
method: 'PATCH',
|
|
165
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
166
|
+
}, (res) => {
|
|
167
|
+
let d = '';
|
|
168
|
+
res.on('data', (c) => { d += c; });
|
|
169
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
170
|
+
});
|
|
171
|
+
req.on('error', reject);
|
|
172
|
+
req.end(payload);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function arrange(row = {}) {
|
|
177
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', detail: {}, modules: null, ...row };
|
|
178
|
+
lastPatch = null; lastPublished = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test('PATCH /detail: an owner changes the declared kind, and the reply is the row\'s own answer', async () => {
|
|
182
|
+
arrange();
|
|
183
|
+
const res = await patchDetail({ type: 'research' });
|
|
184
|
+
assert.equal(res.status, 200, `expected 200, got ${res.status} ${res.raw}`);
|
|
185
|
+
assert.equal(lastPatch.type, 'research', 'the word reaches the patch');
|
|
186
|
+
assert.equal(res.body.type, 'research', 'and comes back, so the caller repaints from the platform, not from what it sent');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('PATCH /detail: the change REACHES the catalog projection — the map cannot keep the old word', async () => {
|
|
190
|
+
arrange();
|
|
191
|
+
await patchDetail({ type: 'non-profit' });
|
|
192
|
+
assert.ok(lastPublished, 'the projection is re-taken on a type change, as it is on a description change');
|
|
193
|
+
assert.equal(lastPublished.type, 'non-profit', 'and it carries the row as updated, not as read');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('PATCH /detail: "not-sure" is a real answer — it is the way back to saying nothing', async () => {
|
|
197
|
+
arrange();
|
|
198
|
+
const res = await patchDetail({ type: 'not-sure' });
|
|
199
|
+
assert.equal(res.status, 200, `expected 200, got ${res.status} ${res.raw}`);
|
|
200
|
+
assert.equal(lastPatch.type, 'not-sure');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('PATCH /detail: null is REFUSED, and the refusal names the word that does the job', async () => {
|
|
204
|
+
// The one place the two un-answer conventions on this route part company:
|
|
205
|
+
// `detail: { key: null }` un-answers a question whose vocabulary has no word for
|
|
206
|
+
// "never said"; the type vocabulary HAS one. A null would also be unsafe — the
|
|
207
|
+
// catalog carries the type as COALESCE(new, old), so it would leave the map
|
|
208
|
+
// showing the declaration the owner had just taken back.
|
|
209
|
+
arrange();
|
|
210
|
+
const res = await patchDetail({ type: null });
|
|
211
|
+
assert.equal(res.status, 400, `expected 400, got ${res.status} ${res.raw}`);
|
|
212
|
+
assert.equal(res.body.error.code, 'bad_type');
|
|
213
|
+
assert.match(res.body.error.message, /not-sure/, 'the refusal says what to send instead');
|
|
214
|
+
assert.equal(lastPatch, null, 'and nothing was written');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('PATCH /detail: an off-vocabulary kind is refused LOUDLY, with the choices listed', async () => {
|
|
218
|
+
arrange();
|
|
219
|
+
const res = await patchDetail({ type: 'startup' });
|
|
220
|
+
assert.equal(res.status, 400, `expected 400, got ${res.status} ${res.raw}`);
|
|
221
|
+
assert.equal(res.body.error.code, 'bad_type');
|
|
222
|
+
for (const k of provisioning.PROJECT_TYPES) {
|
|
223
|
+
assert.match(res.body.error.message, new RegExp(k), `the message offers "${k}"`);
|
|
224
|
+
}
|
|
225
|
+
assert.equal(lastPatch, null, 'a typo is never silently dropped into a stored null');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('PATCH /detail: a type-only body is a real change, not "nothing to change"', async () => {
|
|
229
|
+
arrange();
|
|
230
|
+
const res = await patchDetail({ type: 'business' });
|
|
231
|
+
assert.equal(res.status, 200, `expected 200, got ${res.status} ${res.raw}`);
|
|
232
|
+
assert.deepEqual(Object.keys(lastPatch), ['type'], 'and it carries only the key that was sent');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('PATCH /detail: an empty body still says what may be sent — now including the kind', async () => {
|
|
236
|
+
arrange();
|
|
237
|
+
const res = await patchDetail({});
|
|
238
|
+
assert.equal(res.status, 400);
|
|
239
|
+
assert.match(res.body.error.message, /description, detail, modules, type/,
|
|
240
|
+
'the list of what this route takes must not go stale the moment it grows');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('PATCH /detail: a description-only patch still never touches the declaration', async () => {
|
|
244
|
+
arrange();
|
|
245
|
+
const res = await patchDetail({ description: 'A study of small things.' });
|
|
246
|
+
assert.equal(res.status, 200, `expected 200, got ${res.status} ${res.raw}`);
|
|
247
|
+
assert.equal(lastPatch.type, undefined, 'an omitted key is not an instruction');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// after(), not a bare call: the listener has to outlive every deferred test body.
|
|
251
|
+
after(() => server.close());
|
|
252
|
+
|
|
253
|
+
// ── layer 3 · the manage page's picker ───────────────────────────────────────
|
|
254
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8')
|
|
255
|
+
.replace(/\r\n/g, '\n');
|
|
256
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
257
|
+
|
|
258
|
+
const KIND_RE = /\/\* The declared kind \(task 1003504\)[\s\S]*?doKindPick\(b\.getAttribute\('data-kind'\)\);\n {2}\}\);/;
|
|
259
|
+
const TYPE_META_RE = /var TYPE_META = \{[\s\S]*?\n {2}\};/;
|
|
260
|
+
const kindSlice = (SCRIPT.match(KIND_RE) || [null])[0];
|
|
261
|
+
const typeMetaSlice = (SCRIPT.match(TYPE_META_RE) || [null])[0];
|
|
262
|
+
|
|
263
|
+
test('the kind region is still findable, and sits OUTSIDE the extras region a sibling test slices', () => {
|
|
264
|
+
assert.ok(kindSlice, 'the kind picker was not found in projects.html');
|
|
265
|
+
assert.ok(typeMetaSlice, 'TYPE_META was not found in projects.html');
|
|
266
|
+
assert.match(kindSlice, /function renderKind\(inst\) \{/);
|
|
267
|
+
assert.match(kindSlice, /function doKindPick\(key\) \{/);
|
|
268
|
+
// tests/project_modules_ui.mjs slices `var modsCatalog = null;` → the join-door
|
|
269
|
+
// comment and RUNS it under a DOM stub. A top-level document.addEventListener
|
|
270
|
+
// inside that slice throws there, which is why this block lives above it.
|
|
271
|
+
assert.ok(SCRIPT.indexOf('/* The declared kind (task 1003504)') < SCRIPT.indexOf('var modsCatalog = null;'),
|
|
272
|
+
'the kind block must stay above the extras region');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('the section ships as real markup with its own fold, beside the extras (C7)', () => {
|
|
276
|
+
const sec = HUB.slice(HUB.indexOf('id="mKind"'), HUB.indexOf('id="mModules"'));
|
|
277
|
+
assert.match(sec, /<h2>Its kind<\/h2>/);
|
|
278
|
+
assert.match(sec, /<div id="mKindPick">/, 'the volatile slot');
|
|
279
|
+
assert.match(sec, /<details class="finePrint" id="mKindWhat">/, 'and its fold is a SIBLING, so no re-render drops it');
|
|
280
|
+
assert.match(sec, /never picked your extras yourself/,
|
|
281
|
+
'the fold states the one consequence the owner has to be told about before picking');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// A faithful miniature of the page's DOM: the band is written as a string into one
|
|
285
|
+
// slot (the extras band's own idiom), so the assertions read that markup back.
|
|
286
|
+
function runKind({ inst, fetchImpl, catalogMods } = {}) {
|
|
287
|
+
const els = {};
|
|
288
|
+
const el = (id) => (els[id] || (els[id] = { id, innerHTML: '', textContent: '', className: '' }));
|
|
289
|
+
const calls = { mods: 0, listeners: [] };
|
|
290
|
+
const sandbox = {
|
|
291
|
+
manageInst: inst,
|
|
292
|
+
manageId: inst && inst.id,
|
|
293
|
+
API: '/api/bongos',
|
|
294
|
+
$: el,
|
|
295
|
+
esc: (v) => String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
296
|
+
fetch: fetchImpl || (() => new Promise(() => {})),
|
|
297
|
+
jsonOrEmpty: (res) => Promise.resolve(res.__body || {}),
|
|
298
|
+
apiError: (data) => (data && data.error && data.error.message) || 'error',
|
|
299
|
+
signInAgain: () => '<a href="#">Sign in again</a>',
|
|
300
|
+
renderMods: () => { calls.mods += 1; },
|
|
301
|
+
modsSource: (i) => (i && i.modules && i.modules.source) || null,
|
|
302
|
+
document: { addEventListener: (evt, fn) => calls.listeners.push([evt, fn]) },
|
|
303
|
+
Promise,
|
|
304
|
+
JSON,
|
|
305
|
+
Object,
|
|
306
|
+
String,
|
|
307
|
+
encodeURIComponent,
|
|
308
|
+
};
|
|
309
|
+
sandbox.globalThis = sandbox;
|
|
310
|
+
vm.createContext(sandbox);
|
|
311
|
+
vm.runInContext(`${typeMetaSlice}\n${kindSlice}`, sandbox);
|
|
312
|
+
void catalogMods;
|
|
313
|
+
return { sandbox, els, calls, band: () => el('mKindPick').innerHTML, msg: () => el('mKindMsg') };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const INST = (over = {}) => ({ id: 18, type: 'game', modules: { selected: ['dev-box'], source: 'bundle' }, ...over });
|
|
317
|
+
|
|
318
|
+
test('the band paints every kind from TYPE_META, with the declared one pressed', () => {
|
|
319
|
+
const h = runKind({ inst: INST() });
|
|
320
|
+
h.sandbox.renderKind(h.sandbox.manageInst);
|
|
321
|
+
const band = h.band();
|
|
322
|
+
for (const key of ['game', 'research', 'business', 'non-profit', 'not-sure']) {
|
|
323
|
+
assert.ok(band.includes(`data-kind="${key}"`), `${key} is offered`);
|
|
324
|
+
}
|
|
325
|
+
assert.match(band, /data-kind="game" aria-pressed="true"/, 'the declared one reads as chosen');
|
|
326
|
+
assert.match(band, /data-kind="research" aria-pressed="false"/);
|
|
327
|
+
assert.ok(band.includes('Non-Profit'), 'labels come from the page\'s one vocabulary, never a second copy');
|
|
328
|
+
assert.doesNotMatch(band, /never said what kind it is/, 'a declared project is not told it has said nothing');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a project that never declared shows none pressed, and is told what saying so would do', () => {
|
|
332
|
+
const h = runKind({ inst: INST({ type: null }) });
|
|
333
|
+
h.sandbox.renderKind(h.sandbox.manageInst);
|
|
334
|
+
assert.doesNotMatch(h.band(), /aria-pressed="true"/, 'nothing is invented on the owner\'s behalf');
|
|
335
|
+
assert.match(h.band(), /never said what kind it is/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('re-picking the kind already declared is a NO-OP — the way back is "Not Sure", not a toggle-off', async () => {
|
|
339
|
+
let sent = 0;
|
|
340
|
+
const h = runKind({ inst: INST({ type: 'game' }), fetchImpl: () => { sent += 1; return new Promise(() => {}); } });
|
|
341
|
+
h.sandbox.doKindPick('game');
|
|
342
|
+
assert.equal(sent, 0, 'no write, so the route never has to refuse the null a toggle-off would send');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
function okFetch(body) {
|
|
346
|
+
return (_url, opts) => {
|
|
347
|
+
okFetch.lastBody = JSON.parse(opts.body);
|
|
348
|
+
okFetch.lastUrl = _url;
|
|
349
|
+
return Promise.resolve({ ok: true, status: 200, __body: body });
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
test('a pick sends the kind on the own-scoped detail channel and repaints from the PLATFORM\'S answer', async () => {
|
|
354
|
+
const h = runKind({
|
|
355
|
+
inst: INST({ type: 'game' }),
|
|
356
|
+
fetchImpl: okFetch({ type: 'research', modules: { selected: ['dev-box'], source: 'picked' } }),
|
|
357
|
+
});
|
|
358
|
+
h.sandbox.doKindPick('research');
|
|
359
|
+
await new Promise((r) => setImmediate(r));
|
|
360
|
+
assert.deepEqual(okFetch.lastBody, { type: 'research' }, 'the whole declaration, and nothing else');
|
|
361
|
+
assert.match(okFetch.lastUrl, /\/provisioning\/instances\/18\/detail$/, 'the same route the extras and the description use');
|
|
362
|
+
assert.equal(h.sandbox.manageInst.type, 'research');
|
|
363
|
+
assert.match(h.band(), /data-kind="research" aria-pressed="true"/);
|
|
364
|
+
assert.equal(h.msg().className, 'projMsg ok');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('when the extras followed the kind, the band above is repainted AND the owner is told', async () => {
|
|
368
|
+
// For a project whose picker was skipped, `modules` is NULL and the read resolves
|
|
369
|
+
// the type's starter bundle (ADR 0240 §2) — so changing the kind moves the extras.
|
|
370
|
+
// Leaving that band painted from the old kind would be a lie on the same page.
|
|
371
|
+
const h = runKind({
|
|
372
|
+
inst: INST({ type: 'game', modules: { selected: ['dev-box'], source: 'bundle' } }),
|
|
373
|
+
fetchImpl: okFetch({ type: 'research', modules: { selected: [], source: 'bundle' } }),
|
|
374
|
+
});
|
|
375
|
+
h.sandbox.doKindPick('research');
|
|
376
|
+
await new Promise((r) => setImmediate(r));
|
|
377
|
+
assert.ok(h.calls.mods >= 1, 'the extras band is repainted from the row that just changed');
|
|
378
|
+
assert.match(h.msg().textContent, /follow the kind/, 'and the receipt says so rather than claiming nothing moved');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('a set the owner picked for themselves never moves, and the receipt does not claim it did', async () => {
|
|
382
|
+
const h = runKind({
|
|
383
|
+
inst: INST({ type: 'game', modules: { selected: ['dev-box'], source: 'picked' } }),
|
|
384
|
+
fetchImpl: okFetch({ type: 'business', modules: { selected: ['dev-box'], source: 'picked' } }),
|
|
385
|
+
});
|
|
386
|
+
h.sandbox.doKindPick('business');
|
|
387
|
+
await new Promise((r) => setImmediate(r));
|
|
388
|
+
assert.doesNotMatch(h.msg().textContent, /follow the kind/);
|
|
389
|
+
assert.match(h.msg().textContent, /nothing to restart/, 'the declaration reaches no running instance');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('a refusal is said in the server\'s own words, and the band goes back to what the row still holds', async () => {
|
|
393
|
+
const h = runKind({
|
|
394
|
+
inst: INST({ type: 'game' }),
|
|
395
|
+
fetchImpl: () => Promise.resolve({ ok: false, status: 400, __body: { error: { code: 'bad_type', message: 'Unknown project type "startup".' } } }),
|
|
396
|
+
});
|
|
397
|
+
h.sandbox.doKindPick('research');
|
|
398
|
+
await new Promise((r) => setImmediate(r));
|
|
399
|
+
assert.equal(h.sandbox.manageInst.type, 'game', 'the row is untouched');
|
|
400
|
+
assert.match(h.band(), /data-kind="game" aria-pressed="true"/, 'and so is the band');
|
|
401
|
+
assert.equal(h.msg().className, 'projMsg err');
|
|
402
|
+
assert.match(h.msg().textContent, /Unknown project type/, 'the platform\'s words, not ours');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('a signed-out save offers a way back in rather than a dead end', async () => {
|
|
406
|
+
const h = runKind({
|
|
407
|
+
inst: INST(),
|
|
408
|
+
fetchImpl: () => Promise.resolve({ ok: false, status: 401, __body: {} }),
|
|
409
|
+
});
|
|
410
|
+
h.sandbox.doKindPick('research');
|
|
411
|
+
await new Promise((r) => setImmediate(r));
|
|
412
|
+
assert.match(h.els.mKindMsg.innerHTML, /Sign in again/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('a network failure claims nothing about the project, and the picker unlocks', async () => {
|
|
416
|
+
const h = runKind({ inst: INST(), fetchImpl: () => Promise.reject(new Error('offline')) });
|
|
417
|
+
h.sandbox.doKindPick('research');
|
|
418
|
+
await new Promise((r) => setImmediate(r));
|
|
419
|
+
assert.match(h.msg().textContent, /Nothing about your project has changed/);
|
|
420
|
+
assert.doesNotMatch(h.band(), /disabled/, 'the band is live again, so a retry is one click');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('the picker is wired by DELEGATION, so a repaint can never drop its handler', () => {
|
|
424
|
+
const h = runKind({ inst: INST() });
|
|
425
|
+
assert.equal(h.calls.listeners.length, 1, 'exactly one listener, on the document');
|
|
426
|
+
assert.equal(h.calls.listeners[0][0], 'click');
|
|
427
|
+
});
|
|
@@ -437,10 +437,15 @@ test('decision 6 — the manage section is "Take it offline", and the teardown t
|
|
|
437
437
|
assert.equal((truth.match(/until you bring it back\. Nothing here erases a project\.'/g) || []).length, 2, 'both hosting shapes end on the same sentence');
|
|
438
438
|
});
|
|
439
439
|
|
|
440
|
-
test('decision 5 — the Type step says the type
|
|
440
|
+
test('decision 5 — the Type step says where the type can be changed (task 1003504)', () => {
|
|
441
|
+
// Task 1002772 shipped this step with a NARROWED promise: "it stays as you pick it
|
|
442
|
+
// for now", because no owner-facing route could change a declared type. Task 1003504
|
|
443
|
+
// built that route and the manage page's "Its kind" picker on it, so the sentence is
|
|
444
|
+
// earned back — and it now has to name WHERE, or it is a promise with no address.
|
|
441
445
|
const type = (HUB.match(/<section class="panel" id="panel-type"[\s\S]*?<\/section>/) || [''])[0];
|
|
442
446
|
assert.ok(type, '#panel-type exists');
|
|
443
|
-
assert.match(type, /<p class="lede">Pick the shape that fits best — it’s how the project is listed on the map, and it
|
|
447
|
+
assert.match(type, /<p class="lede">Pick the shape that fits best — it’s how the project is listed on the map, and you can change it later from the project’s own page\. Not sure is a fine answer\.<\/p>/);
|
|
448
|
+
assert.doesNotMatch(type, /stays as you pick it/, 'the narrowed promise must not survive beside the wider one');
|
|
444
449
|
});
|
|
445
450
|
|
|
446
451
|
test('decision 8 — a card carrying a red error paragraph never tallies under Live', () => {
|