@bongos/core 1.19.381 → 1.19.382
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 +43 -28
- 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 +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +141 -3
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +14 -9
- package/docs/copy-registry.json +87 -42
- package/migrations/core_228_idea_inbox_captured_by_idx.sql +61 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/hall-kit.js +67 -6
- package/modules/hall-ui/public/profile.html +34 -2
- package/modules/hall-ui/public/profile.js +279 -0
- package/modules/ideas/inbox.js +98 -0
- package/modules/ideas/routes/inbox.js +39 -0
- package/modules/lifecycle/activity.js +205 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +13 -3
- package/src/bongos/routes/builders.js +67 -0
- package/src/module-api.js +3 -1
- package/tests/profile_activity.mjs +625 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// tests/profile_activity.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003281 (goal 1000072, criterion legibility-activity-by-role) — "See what
|
|
4
|
+
// you did, by role", including the idea trail.
|
|
5
|
+
//
|
|
6
|
+
// The done-when, as assertions: the owner opens their profile, switches to the
|
|
7
|
+
// ideator view, sees EVERY idea they ever captured with what became of it, and
|
|
8
|
+
// can click through to the task a promotion produced.
|
|
9
|
+
//
|
|
10
|
+
// Five things are pinned here, and each one is a bug that is invisible in review:
|
|
11
|
+
// 1. ROUTE ORDER — GET /inbox/by-builder/:id must be declared before
|
|
12
|
+
// GET /inbox/:id, or `:id` binds the literal "by-builder" and parseId 400s
|
|
13
|
+
// the entire trail. The fourth route in that file to need this.
|
|
14
|
+
// 2. THE FOUR OUTCOMES ALL RENDER, and an OPEN idea reads as "still open"
|
|
15
|
+
// rather than as an absence — the criterion names open as an outcome.
|
|
16
|
+
// 3. THE CRAFT AXIS REACHES THE SERVER — switching to a craft tab must change
|
|
17
|
+
// the request, not filter a page the browser already holds. A client-side
|
|
18
|
+
// filter over a page window shows the wrong count and the wrong rows.
|
|
19
|
+
// 4. PRIVATE GOALS ARE FILTERED IN SQL, not after the LIMIT — thinning rows
|
|
20
|
+
// after paging makes the pager lie about the total.
|
|
21
|
+
// 5. SIGNED OUT, the activity section says why it is empty and issues NO
|
|
22
|
+
// activity requests; the public portfolio above it is untouched.
|
|
23
|
+
//
|
|
24
|
+
// DB-free: `api.pool.query` is patched before the modules load (the
|
|
25
|
+
// idea_spark_queue.mjs idiom), so the REAL SQL is captured and the REAL handlers
|
|
26
|
+
// run. The page half runs the REAL profile.js in a vm against a stub DOM and a
|
|
27
|
+
// stub OTBKit that captures each ledger's rowHtml (the idea_detail_page.mjs
|
|
28
|
+
// idiom) — so what is asserted is the HTML a reader would actually get.
|
|
29
|
+
//
|
|
30
|
+
// Run: node tests/profile_activity.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import vm from 'node:vm';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
async function test(name, fn) {
|
|
45
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
46
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// 0. the doorway pool, patched before anything requires it
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const api = require('../src/module-api.js');
|
|
54
|
+
let queries = [];
|
|
55
|
+
let nextRows = [];
|
|
56
|
+
let nextCount = 0;
|
|
57
|
+
// A `count(*)` in the SQL cannot tell the two apart — the goals PAGE query
|
|
58
|
+
// carries `count(*) FILTER (…)` inside its rollup CTE. The page window is what
|
|
59
|
+
// distinguishes them, so LIMIT is the discriminator here and below.
|
|
60
|
+
const isPageQuery = (sql) => /\sLIMIT\s/i.test(sql);
|
|
61
|
+
api.pool.query = async (sql, params = []) => {
|
|
62
|
+
queries.push({ sql, params });
|
|
63
|
+
if (!isPageQuery(sql)) return { rows: [{ n: nextCount, total: nextCount }] };
|
|
64
|
+
return { rows: nextRows };
|
|
65
|
+
};
|
|
66
|
+
const resetSql = () => { queries = []; nextRows = []; nextCount = 0; };
|
|
67
|
+
const listSql = () => queries.find((q) => isPageQuery(q.sql));
|
|
68
|
+
const countSql = () => queries.find((q) => !isPageQuery(q.sql));
|
|
69
|
+
|
|
70
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
71
|
+
const activity = require('../modules/lifecycle/activity.js');
|
|
72
|
+
const ideaRoutesFactory = require('../modules/ideas/routes/inbox.js');
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// 1. route order — the invisible 400
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
const ideaRouter = ideaRoutesFactory({});
|
|
79
|
+
const mountedPaths = ideaRouter.stack.filter((l) => l.route).map((l) => l.route.path);
|
|
80
|
+
|
|
81
|
+
await test('GET /inbox/by-builder/:id is declared BEFORE GET /inbox/:id', () => {
|
|
82
|
+
const trail = mountedPaths.indexOf('/inbox/by-builder/:id');
|
|
83
|
+
const byId = mountedPaths.indexOf('/inbox/:id');
|
|
84
|
+
assert.ok(trail > -1, 'the trail route is mounted');
|
|
85
|
+
assert.ok(byId > -1, 'the by-id route is mounted');
|
|
86
|
+
assert.ok(trail < byId, `/inbox/by-builder/:id (${trail}) must precede /inbox/:id (${byId}) or :id swallows it`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// 2. the trail route — shape, clamping, and the rank gate riding through
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function handlerFor(router, pathStr, method) {
|
|
94
|
+
const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
|
|
95
|
+
assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
|
|
96
|
+
const stack = layer.route.stack.map((s) => s.handle);
|
|
97
|
+
return stack[stack.length - 1];
|
|
98
|
+
}
|
|
99
|
+
const trailHandler = handlerFor(ideaRouter, '/inbox/by-builder/:id', 'get');
|
|
100
|
+
|
|
101
|
+
function callTrail({ params = { id: '95' }, query = {}, rank = 'metic' } = {}) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const req = { params, query, body: {}, builder: { id: '9', rank } };
|
|
104
|
+
const res = {
|
|
105
|
+
statusCode: 200,
|
|
106
|
+
status(c) { this.statusCode = c; return this; },
|
|
107
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
108
|
+
fail(code, status, extra) {
|
|
109
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
110
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
Promise.resolve(trailHandler(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await test('the trail returns every idea this builder filed, with its total', async () => {
|
|
118
|
+
resetSql();
|
|
119
|
+
nextRows = [{ id: '1', title: 'A', status: 'open' }, { id: '2', title: 'B', status: 'promoted' }];
|
|
120
|
+
nextCount = 2;
|
|
121
|
+
const r = await callTrail();
|
|
122
|
+
assert.equal(r.status, 200);
|
|
123
|
+
assert.equal(r.payload.builder_id, 95);
|
|
124
|
+
assert.equal(r.payload.ideas.length, 2);
|
|
125
|
+
assert.equal(r.payload.total, 2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('the trail is scoped to the PERSON, not to a status — a triaged idea stays in it', async () => {
|
|
129
|
+
resetSql();
|
|
130
|
+
await callTrail();
|
|
131
|
+
const sql = listSql().sql;
|
|
132
|
+
assert.ok(/i\.captured_by = \$1/.test(sql), 'scoped by captured_by');
|
|
133
|
+
assert.ok(!/i\.status\s*=\s*'open'/.test(sql), "the trail must NOT filter to status='open' — that is the bug it exists to fix");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test('the trail resolves each outcome to a NAME, not just an id', async () => {
|
|
137
|
+
resetSql();
|
|
138
|
+
await callTrail();
|
|
139
|
+
const sql = listSql().sql;
|
|
140
|
+
assert.ok(/LEFT JOIN tasks pt ON pt\.id = i\.promoted_to_task_id/.test(sql), 'the promoted task is joined for its title');
|
|
141
|
+
assert.ok(/LEFT JOIN idea_inbox mi ON mi\.id = i\.merged_into_idea_id/.test(sql), 'the merge target is joined for its title');
|
|
142
|
+
assert.ok(/ideas_task_lineage/.test(sql), 'the one-idea-many-tasks lineage rides along');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('the list and the count share ONE predicate, viewer rank included', async () => {
|
|
146
|
+
resetSql();
|
|
147
|
+
await callTrail({ rank: 'xenos' });
|
|
148
|
+
const list = listSql().sql;
|
|
149
|
+
const count = countSql().sql;
|
|
150
|
+
// A Xenos must not be told there are N ideas when skill-friction rows are
|
|
151
|
+
// filtered out of the list they can see.
|
|
152
|
+
assert.ok(/title NOT LIKE/.test(list), 'the list applies the skill-friction gate for a Xenos');
|
|
153
|
+
assert.ok(/title NOT LIKE/.test(count), 'the count applies the SAME gate — a pill that disagrees with its rows is the classic bug');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('an out-of-range limit clamps instead of 400ing a profile mid-render', async () => {
|
|
157
|
+
resetSql();
|
|
158
|
+
await callTrail({ query: { limit: '9999' } });
|
|
159
|
+
const params = listSql().params;
|
|
160
|
+
assert.equal(params[params.length - 2], inbox.IDEA_TRAIL_DEFAULT_LIMIT);
|
|
161
|
+
resetSql();
|
|
162
|
+
await callTrail({ query: { limit: '50', offset: '100' } });
|
|
163
|
+
const p2 = listSql().params;
|
|
164
|
+
assert.equal(p2[p2.length - 2], 50);
|
|
165
|
+
assert.equal(p2[p2.length - 1], 100);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('a bad builder id is a 400, not a whole-table read', async () => {
|
|
169
|
+
resetSql();
|
|
170
|
+
const r = await callTrail({ params: { id: 'not-a-number' } });
|
|
171
|
+
assert.equal(r.status, 400);
|
|
172
|
+
assert.equal(queries.length, 0, 'nothing was queried');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// 3. the lifecycle reads — the craft axis and the private-goal wall, in SQL
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
await test('tasks: no craft means the WHOLE record, unclassified included', async () => {
|
|
180
|
+
resetSql();
|
|
181
|
+
await activity.activityTasksByBuilder(95, {});
|
|
182
|
+
const sql = listSql().sql;
|
|
183
|
+
assert.ok(!/discipline/.test(sql.split('SELECT')[2] || sql.split('WHERE')[1] || ''), 'no discipline predicate when no craft is asked for');
|
|
184
|
+
assert.ok(/t\.shipped_by = \$1/.test(sql) && /t\.status = 'shipped'/.test(sql), 'completed means shipped by this builder');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('tasks: a craft filters on discipline, and the count filters the same way', async () => {
|
|
188
|
+
resetSql();
|
|
189
|
+
await activity.activityTasksByBuilder(95, { role: 'artist' });
|
|
190
|
+
assert.ok(/COALESCE\(t\.discipline, 'unclassified'\) = \$2/.test(listSql().sql), 'the page filters by craft');
|
|
191
|
+
assert.ok(/COALESCE\(t\.discipline, 'unclassified'\) = \$2/.test(countSql().sql), 'the count filters by craft too');
|
|
192
|
+
assert.equal(listSql().params[1], 'artist');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test('tasks: an unknown craft reads as NO filter, not as an error', async () => {
|
|
196
|
+
resetSql();
|
|
197
|
+
await activity.activityTasksByBuilder(95, { role: 'wizard' });
|
|
198
|
+
assert.ok(!/discipline\) = \$2/.test(listSql().sql), 'a craft nobody has shows the whole record');
|
|
199
|
+
assert.equal(activity.normalizeCraft('wizard'), null);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('goals: a private goal is walled off in SQL, before the LIMIT', async () => {
|
|
203
|
+
resetSql();
|
|
204
|
+
await activity.activityGoalsByBuilder(95, { viewerId: 9, viewerIsArchon: false });
|
|
205
|
+
const sql = listSql().sql;
|
|
206
|
+
assert.ok(/g\.visibility = 'public'/.test(sql), 'public goals always');
|
|
207
|
+
assert.ok(/goal_members gm/.test(sql), 'a member sees their private goal');
|
|
208
|
+
assert.ok(/\$3::boolean/.test(sql), 'an Archon sees all of them');
|
|
209
|
+
const limitAt = sql.indexOf('LIMIT');
|
|
210
|
+
assert.ok(sql.indexOf("visibility = 'public'") < limitAt, 'the wall is inside the query, not a post-LIMIT thin');
|
|
211
|
+
assert.ok(/g\.visibility = 'public'/.test(countSql().sql), 'the count is walled identically or the total lies');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test("goals: a goal's craft is what its TASKS carry — no discipline column on goals", async () => {
|
|
215
|
+
resetSql();
|
|
216
|
+
await activity.activityGoalsByBuilder(95, { role: 'engineer' });
|
|
217
|
+
const sql = listSql().sql;
|
|
218
|
+
assert.ok(/array_agg\(DISTINCT COALESCE\(t\.discipline/.test(sql), 'the craft set is derived from the tasks');
|
|
219
|
+
assert.ok(/= ANY\(c\.disciplines\)/.test(sql), 'the craft filter tests that derived set');
|
|
220
|
+
assert.ok(!/goals[^)]*\.discipline/.test(sql), 'goals are never given a discipline column');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('both reads clamp their page window', () => {
|
|
224
|
+
assert.equal(activity.clampLimit(0), activity.DEFAULT_LIMIT);
|
|
225
|
+
assert.equal(activity.clampLimit(10_000), activity.MAX_LIMIT);
|
|
226
|
+
assert.equal(activity.clampOffset(-5), 0);
|
|
227
|
+
assert.equal(activity.clampOffset(40), 40);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// 3b. the per-person predicates have indexes to stand on
|
|
232
|
+
//
|
|
233
|
+
// Every idea read before this one was scoped to status + captured_at, and every
|
|
234
|
+
// task read to version + status, so those are the columns migration 006/003
|
|
235
|
+
// indexed. This task introduced three PER-PERSON predicates, each on a route a
|
|
236
|
+
// signed-in builder hits repeatedly and each paired with a COUNT(*) that has no
|
|
237
|
+
// LIMIT to escape through — unindexed, they sequentially scan a shared table on
|
|
238
|
+
// every request (grader panel finding, efficiency worker). A static check,
|
|
239
|
+
// because an index is invisible to a DB-free suite in every other way: it pins
|
|
240
|
+
// that the migration exists and still names the columns the queries filter on.
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const MIGRATIONS = fs.readdirSync(path.join(ROOT, 'migrations'))
|
|
244
|
+
.filter((f) => f.endsWith('.sql'))
|
|
245
|
+
.map((f) => fs.readFileSync(path.join(ROOT, 'migrations', f), 'utf8'))
|
|
246
|
+
.join('\n');
|
|
247
|
+
|
|
248
|
+
await test('the idea trail has an index on (captured_by, captured_at)', () => {
|
|
249
|
+
assert.match(
|
|
250
|
+
MIGRATIONS,
|
|
251
|
+
/CREATE INDEX[^;]*ON idea_inbox \(captured_by, captured_at DESC\)/i,
|
|
252
|
+
'the trail filters captured_by and orders captured_at — equality leads, sort trails'
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await test('the works half has an index its shipped-only predicate can use', () => {
|
|
257
|
+
assert.match(
|
|
258
|
+
MIGRATIONS,
|
|
259
|
+
/CREATE INDEX[^;]*ON tasks \(shipped_by, shipped_at DESC\) WHERE status = 'shipped'/i,
|
|
260
|
+
"core_193's index on shipped_by is partial on `WHERE needs_rebase` and cannot serve this"
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await test('the goals rollup has an index on tasks.goal_id', () => {
|
|
265
|
+
assert.match(MIGRATIONS, /CREATE INDEX[^;]*ON tasks \(goal_id\)/i);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// 4. the core route — the gate, the 404, and the section switch
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
const authPath = require.resolve('../src/bongos/auth.js');
|
|
273
|
+
let authedBuilder = { id: 9, rank: 'metic' };
|
|
274
|
+
require.cache[authPath] = {
|
|
275
|
+
id: authPath,
|
|
276
|
+
filename: authPath,
|
|
277
|
+
loaded: true,
|
|
278
|
+
exports: {
|
|
279
|
+
requireBuilder: (req, res, next) => {
|
|
280
|
+
if (!authedBuilder) return res.fail('unauthorized', 401);
|
|
281
|
+
req.builder = authedBuilder;
|
|
282
|
+
return next();
|
|
283
|
+
},
|
|
284
|
+
requireRank: () => (_req, _res, next) => next(),
|
|
285
|
+
requirePermission: () => (_req, _res, next) => next(),
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const dbPath = require.resolve('../src/bongos/db.js');
|
|
290
|
+
require.cache[dbPath] = {
|
|
291
|
+
id: dbPath,
|
|
292
|
+
filename: dbPath,
|
|
293
|
+
loaded: true,
|
|
294
|
+
exports: {
|
|
295
|
+
RANKS: ['xenos', 'thetes', 'metic', 'archon'],
|
|
296
|
+
getBuilderById: async (id) => (Number(id) === 95 ? { id: 95, github_login: 'jaxri' } : null),
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const seams = require('../src/module-seams.js');
|
|
301
|
+
seams._reset();
|
|
302
|
+
let portCalls = [];
|
|
303
|
+
seams.registerProvider('lifecycle', {
|
|
304
|
+
countShippedTasksForBuilder: async () => 3,
|
|
305
|
+
recentShippedTasksByBuilder: async () => [],
|
|
306
|
+
activityTasksByBuilder: async (id, opts) => {
|
|
307
|
+
portCalls.push(['tasks', id, opts]);
|
|
308
|
+
return { rows: [{ id: 42, title: 'A work', discipline: 'engineer' }], total: 1 };
|
|
309
|
+
},
|
|
310
|
+
activityGoalsByBuilder: async (id, opts) => {
|
|
311
|
+
portCalls.push(['goals', id, opts]);
|
|
312
|
+
return { rows: [{ id: 7, title: 'A goal', disciplines: ['artist'] }], total: 1 };
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const express = require('express');
|
|
317
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
318
|
+
const buildBuildersRouter = require('../src/bongos/routes/builders.js');
|
|
319
|
+
|
|
320
|
+
const app = express();
|
|
321
|
+
app.use(express.json());
|
|
322
|
+
app.use(attachFail);
|
|
323
|
+
app.use('/api/gds', buildBuildersRouter());
|
|
324
|
+
const http = await import('node:http');
|
|
325
|
+
const server = await new Promise((resolve) => {
|
|
326
|
+
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
327
|
+
});
|
|
328
|
+
const port = server.address().port;
|
|
329
|
+
|
|
330
|
+
function get(pathStr) {
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: pathStr }, (res) => {
|
|
333
|
+
const chunks = [];
|
|
334
|
+
res.on('data', (c) => chunks.push(c));
|
|
335
|
+
res.on('end', () => {
|
|
336
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
337
|
+
let json = null;
|
|
338
|
+
try { json = JSON.parse(text); } catch { /* not json */ }
|
|
339
|
+
resolve({ status: res.statusCode, json, headers: res.headers });
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
req.on('error', reject);
|
|
343
|
+
req.end();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await test('the activity route is GATED — the public profile beside it is not', async () => {
|
|
348
|
+
portCalls = [];
|
|
349
|
+
authedBuilder = null;
|
|
350
|
+
const r = await get('/api/gds/builders/95/activity');
|
|
351
|
+
assert.equal(r.status, 401);
|
|
352
|
+
assert.equal(portCalls.length, 0, 'no read happened');
|
|
353
|
+
authedBuilder = { id: 9, rank: 'metic' };
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await test('section defaults to tasks and role rides through to the port', async () => {
|
|
357
|
+
portCalls = [];
|
|
358
|
+
const r = await get('/api/gds/builders/95/activity?role=artist');
|
|
359
|
+
assert.equal(r.status, 200);
|
|
360
|
+
assert.equal(r.json.section, 'tasks');
|
|
361
|
+
assert.equal(r.json.role, 'artist');
|
|
362
|
+
assert.equal(r.json.total, 1);
|
|
363
|
+
assert.equal(portCalls[0][0], 'tasks');
|
|
364
|
+
assert.equal(portCalls[0][2].role, 'artist');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await test('section=goals carries the VIEWER, because private goals depend on who asks', async () => {
|
|
368
|
+
portCalls = [];
|
|
369
|
+
authedBuilder = { id: 9, rank: 'archon' };
|
|
370
|
+
const r = await get('/api/gds/builders/95/activity?section=goals');
|
|
371
|
+
assert.equal(r.status, 200);
|
|
372
|
+
assert.equal(r.json.section, 'goals');
|
|
373
|
+
assert.equal(portCalls[0][0], 'goals');
|
|
374
|
+
assert.equal(portCalls[0][2].viewerId, 9);
|
|
375
|
+
assert.equal(portCalls[0][2].viewerIsArchon, true);
|
|
376
|
+
authedBuilder = { id: 9, rank: 'metic' };
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await test('a viewer-dependent read is never cached', async () => {
|
|
380
|
+
const r = await get('/api/gds/builders/95/activity');
|
|
381
|
+
assert.equal(r.headers['cache-control'], 'no-store');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await test('an unknown builder is a 404 and a bad id a 400', async () => {
|
|
385
|
+
assert.equal((await get('/api/gds/builders/9999/activity')).status, 404);
|
|
386
|
+
assert.equal((await get('/api/gds/builders/abc/activity')).status, 400);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
server.close();
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// 5. the kit's server-offset pager mode
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
globalThis.document = { getElementById: () => null };
|
|
396
|
+
const OTBKit = require('../modules/hall-ui/public/hall-kit.js');
|
|
397
|
+
|
|
398
|
+
await test('pageOffset turns a page number into the offset a server takes', () => {
|
|
399
|
+
const p = OTBKit.makePager('x', 'k', { defaultPerPage: 10 });
|
|
400
|
+
assert.equal(OTBKit.pageOffset(p), 0);
|
|
401
|
+
p.page = 3;
|
|
402
|
+
assert.equal(OTBKit.pageOffset(p), 20);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await test('renderServerPage clamps a page that outran a shrunken set, and says it did', () => {
|
|
406
|
+
const p = OTBKit.makePager('x', 'k', { defaultPerPage: 10 });
|
|
407
|
+
p.page = 9;
|
|
408
|
+
assert.equal(OTBKit.renderServerPage(p, 15, 0), true, 'reports the clamp so the caller refetches');
|
|
409
|
+
assert.equal(p.page, 2);
|
|
410
|
+
assert.equal(OTBKit.renderServerPage(p, 15, 5), false, 'an in-range page is left alone');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await test('a ledger cannot be both client-sliced and server-paged', () => {
|
|
414
|
+
const p = OTBKit.makePager('x', 'k', {});
|
|
415
|
+
assert.throws(
|
|
416
|
+
() => OTBKit.makeLedger({ mount: {}, rowHtml: () => '', pager: p, serverPager: p }),
|
|
417
|
+
/not both/
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// 6. the page — the four outcomes, the craft axis, and the signed-out state
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
426
|
+
|
|
427
|
+
// The four outcomes the criterion names, one row each. `open` is included
|
|
428
|
+
// precisely because it must read as an outcome and not as an absence.
|
|
429
|
+
const TRAIL = [
|
|
430
|
+
{
|
|
431
|
+
id: '901', title: 'A shared changelog', status: 'promoted', captured_at: '2026-08-01T00:00:00Z',
|
|
432
|
+
promoted_to_task_id: '1003281', promoted_task_title: 'See what you did, by role',
|
|
433
|
+
lineage_tasks: [{ task_id: '1003281', origin: 'promote', title: 'See what you did, by role', status: 'shipped' }],
|
|
434
|
+
},
|
|
435
|
+
{ id: '902', title: 'A second thought', status: 'discarded', captured_at: '2026-07-01T00:00:00Z', triage_note: 'Covered by goal 1000072.' },
|
|
436
|
+
{ id: '903', title: 'A duplicate', status: 'merged', captured_at: '2026-06-01T00:00:00Z', merged_into_idea_id: '901', merged_into_idea_title: 'A shared changelog' },
|
|
437
|
+
{ id: '904', title: 'Still thinking', status: 'open', captured_at: '2026-05-01T00:00:00Z' },
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
// Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
|
|
441
|
+
// ledger's rowHtml, and return what it rendered plus every request it made.
|
|
442
|
+
function bootProfile({ signedIn = true } = {}) {
|
|
443
|
+
const els = new Map();
|
|
444
|
+
const makeEl = () => ({
|
|
445
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
446
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
447
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
448
|
+
addEventListener() {},
|
|
449
|
+
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
450
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
451
|
+
});
|
|
452
|
+
const documentObj = {
|
|
453
|
+
title: '',
|
|
454
|
+
body: { dataset: { page: 'profile' } },
|
|
455
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
456
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
457
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
458
|
+
querySelectorAll() { return []; },
|
|
459
|
+
createElement() { return makeEl(); },
|
|
460
|
+
addEventListener() {},
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const requests = [];
|
|
464
|
+
const client = {
|
|
465
|
+
request: async (method, url) => {
|
|
466
|
+
requests.push(url);
|
|
467
|
+
if (url.endsWith('/me')) {
|
|
468
|
+
return signedIn
|
|
469
|
+
? { status: 200, ok: true, data: { builder: { id: 95, rank: 'metic' } } }
|
|
470
|
+
: { status: 401, ok: false, data: null };
|
|
471
|
+
}
|
|
472
|
+
if (/\/builders\/\d+\/profile$/.test(url)) {
|
|
473
|
+
return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [] } };
|
|
474
|
+
}
|
|
475
|
+
if (url.includes('/activity?')) {
|
|
476
|
+
const isGoals = url.includes('section=goals');
|
|
477
|
+
return { status: 200, ok: true, data: { rows: isGoals ? [{ id: 7, title: 'A goal', disciplines: ['artist'], task_shipped: 1, task_total: 2 }] : [{ id: 42, title: 'A work', discipline: 'engineer', shipped_at: '2026-08-01T00:00:00Z' }], total: 1 } };
|
|
478
|
+
}
|
|
479
|
+
if (url.includes('/inbox/by-builder/')) {
|
|
480
|
+
return { status: 200, ok: true, data: { ideas: TRAIL, total: TRAIL.length } };
|
|
481
|
+
}
|
|
482
|
+
return { status: 404, ok: false, data: null };
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// The captured ledgers, keyed by the mount element they were given.
|
|
487
|
+
const ledgers = new Map();
|
|
488
|
+
let tabsOnChange = null;
|
|
489
|
+
const kit = {
|
|
490
|
+
makePager: (prefix, key, opts = {}) => ({
|
|
491
|
+
prefix, key, page: 1, perPage: opts.defaultPerPage || 25,
|
|
492
|
+
perPageOptions: [10, 25], minPerPage: opts.minPerPage ?? 10,
|
|
493
|
+
}),
|
|
494
|
+
pageOffset: (p) => Math.max(0, (p.page - 1) * p.perPage),
|
|
495
|
+
makeLedger: ({ mount, rowHtml }) => {
|
|
496
|
+
const rec = { rows: [], total: 0, html: '' };
|
|
497
|
+
const ledger = {
|
|
498
|
+
render(rows, opts = {}) {
|
|
499
|
+
rec.rows = rows || [];
|
|
500
|
+
rec.total = opts.total || 0;
|
|
501
|
+
rec.html = rec.rows.map((r, i) => rowHtml(r, i)).join('');
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
ledgers.set(mount, rec);
|
|
505
|
+
return ledger;
|
|
506
|
+
},
|
|
507
|
+
makeTabs: ({ tabs, onChange }) => {
|
|
508
|
+
let current = tabs[0].id;
|
|
509
|
+
tabsOnChange = onChange;
|
|
510
|
+
onChange(current);
|
|
511
|
+
return { active: () => current, activate(id) { current = id; onChange(id); } };
|
|
512
|
+
},
|
|
513
|
+
shipVisualThumbHtml: () => '',
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const windowObj = {
|
|
517
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
518
|
+
BongosClient: { createClient: () => client },
|
|
519
|
+
OTBKit: kit,
|
|
520
|
+
OTB: {
|
|
521
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
522
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
523
|
+
fmtNum: (n) => String(n ?? 0),
|
|
524
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
525
|
+
rankLabel: (r) => r,
|
|
526
|
+
toast() {},
|
|
527
|
+
},
|
|
528
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
|
|
529
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
530
|
+
addEventListener() {},
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const sandbox = {
|
|
534
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
535
|
+
console, setTimeout, clearTimeout, URLSearchParams,
|
|
536
|
+
};
|
|
537
|
+
sandbox.globalThis = sandbox;
|
|
538
|
+
vm.createContext(sandbox);
|
|
539
|
+
vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
|
|
540
|
+
|
|
541
|
+
const htmlFor = (mountId) => {
|
|
542
|
+
const rec = ledgers.get(documentObj.getElementById(mountId));
|
|
543
|
+
return rec ? rec.html : null;
|
|
544
|
+
};
|
|
545
|
+
return {
|
|
546
|
+
requests,
|
|
547
|
+
htmlFor,
|
|
548
|
+
switchCraft: (id) => { if (tabsOnChange) tabsOnChange(id); },
|
|
549
|
+
el: (id) => documentObj.getElementById(id),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
554
|
+
|
|
555
|
+
await test('the ideator view shows EVERY idea filed, whatever became of it', async () => {
|
|
556
|
+
const p = bootProfile();
|
|
557
|
+
await settle(); await settle(); await settle();
|
|
558
|
+
const html = p.htmlFor('activity-ideas');
|
|
559
|
+
assert.ok(html, 'the idea trail rendered');
|
|
560
|
+
for (const idea of TRAIL) {
|
|
561
|
+
assert.ok(html.includes(idea.title), `"${idea.title}" (${idea.status}) is in the trail`);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
await test('a promotion links THROUGH to the task it produced — the done-when click', async () => {
|
|
566
|
+
const p = bootProfile();
|
|
567
|
+
await settle(); await settle(); await settle();
|
|
568
|
+
const html = p.htmlFor('activity-ideas');
|
|
569
|
+
assert.ok(html.includes('/task/1003281'), 'the promoted task is a link, not a bare number');
|
|
570
|
+
assert.ok(html.includes('See what you did, by role'), 'and it is named, so the reader need not go look it up');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await test('the other three outcomes each say what happened', async () => {
|
|
574
|
+
const p = bootProfile();
|
|
575
|
+
await settle(); await settle(); await settle();
|
|
576
|
+
const html = p.htmlFor('activity-ideas');
|
|
577
|
+
assert.ok(/Discarded/.test(html), 'a discarded idea says so');
|
|
578
|
+
assert.ok(html.includes('Covered by goal 1000072.'), "and carries the triager's reason");
|
|
579
|
+
assert.ok(/Merged into/.test(html) && html.includes('/idea/901'), 'a merge links to the idea that absorbed it');
|
|
580
|
+
assert.ok(/Still open/.test(html), 'AN OPEN IDEA READS AS OPEN — not as nothing');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await test('switching craft re-asks the SERVER — it does not filter a page in the browser', async () => {
|
|
584
|
+
const p = bootProfile();
|
|
585
|
+
await settle(); await settle(); await settle();
|
|
586
|
+
const before = p.requests.length;
|
|
587
|
+
p.switchCraft('artist');
|
|
588
|
+
await settle(); await settle();
|
|
589
|
+
const after = p.requests.slice(before);
|
|
590
|
+
assert.ok(after.some((u) => u.includes('section=tasks') && u.includes('role=artist')), 'tasks are re-fetched for the craft');
|
|
591
|
+
assert.ok(after.some((u) => u.includes('section=goals') && u.includes('role=artist')), 'goals are re-fetched for the craft');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
await test('the idea trail belongs to the ideator view, not to every craft', async () => {
|
|
595
|
+
const p = bootProfile();
|
|
596
|
+
await settle(); await settle(); await settle();
|
|
597
|
+
assert.equal(p.el('activity-ideas-panel').hidden, false, 'the default "all" view is the whole record, trail included');
|
|
598
|
+
p.switchCraft('engineer');
|
|
599
|
+
await settle();
|
|
600
|
+
assert.equal(p.el('activity-ideas-panel').hidden, true, 'an engineer view is engineer work');
|
|
601
|
+
p.switchCraft('ideator');
|
|
602
|
+
await settle();
|
|
603
|
+
assert.equal(p.el('activity-ideas-panel').hidden, false, 'filing an idea IS ideator work');
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
await test('the ledgers page server-side — the request carries a window, not the whole record', async () => {
|
|
607
|
+
const p = bootProfile();
|
|
608
|
+
await settle(); await settle(); await settle();
|
|
609
|
+
const tasksReq = p.requests.find((u) => u.includes('section=tasks'));
|
|
610
|
+
assert.ok(/limit=\d+/.test(tasksReq) && /offset=\d+/.test(tasksReq), 'limit + offset ride on every activity request');
|
|
611
|
+
const trailReq = p.requests.find((u) => u.includes('/inbox/by-builder/'));
|
|
612
|
+
assert.ok(/limit=\d+/.test(trailReq) && /offset=\d+/.test(trailReq), 'so does the trail — "every idea" can be a long list');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await test('signed out: the section says why, and asks the server for nothing', async () => {
|
|
616
|
+
const p = bootProfile({ signedIn: false });
|
|
617
|
+
await settle(); await settle(); await settle();
|
|
618
|
+
assert.ok(!p.requests.some((u) => u.includes('/activity')), 'no activity read');
|
|
619
|
+
assert.ok(!p.requests.some((u) => u.includes('/inbox/by-builder/')), 'no trail read');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
625
|
+
process.exit(failed ? 1 : 0);
|