@bongos/core 1.23.19 → 1.23.21
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 +82 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +107 -101
- package/docs/copy-registry.json +189 -125
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -1
- package/docs/page-readings.json +73 -58
- package/modules/copy-desk/chrome-pages.js +178 -0
- package/modules/copy-desk/page-data.js +34 -2
- package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
- package/modules/platform-identity/builder-of-the-week.js +18 -9
- package/modules/platform-identity/community-leaderboard.js +3 -0
- package/modules/platform-identity/community-movers.js +152 -0
- package/modules/platform-identity/routes/community-leaderboard.js +22 -2
- package/modules/public-landing/public/community-movers-none.states.json +15 -0
- package/modules/public-landing/public/community.html +62 -4
- package/modules/public-landing/public/community.states.json +1 -1
- package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
- package/modules/ui-design/kit/serve.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/copy-apply.js +3 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/bongos/tweak-renders.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/community_movers.mjs +280 -0
- package/tests/community_movers_db.mjs +147 -0
- package/tests/copy_desk_page_docx.mjs +3 -3
- package/tests/copy_desk_page_editor.mjs +6 -6
- package/tests/copy_desk_page_next.mjs +3 -2
- package/tests/copy_desk_page_reads.mjs +2 -1
- package/tests/copy_desk_page_submit.mjs +30 -2
- package/tests/hub_community_page.mjs +78 -9
- package/tests/privacy_matrix.mjs +48 -9
- package/tests/profile_role_stats.mjs +2 -1
package/release-notes.json
CHANGED
|
@@ -10503,5 +10503,17 @@
|
|
|
10503
10503
|
"id": "1004902",
|
|
10504
10504
|
"text": "The community page now opens on a Community home: the builder of the week, of the month and of the year side by side, how they are chosen, and a button to each community page. The leaderboard has its own page now, and Communit"
|
|
10505
10505
|
}
|
|
10506
|
+
],
|
|
10507
|
+
"1.23.20": [
|
|
10508
|
+
{
|
|
10509
|
+
"id": "1002983",
|
|
10510
|
+
"text": "The Community home now shows a rising builder and the projects on the move this month, under the builders of the week, month and year."
|
|
10511
|
+
}
|
|
10512
|
+
],
|
|
10513
|
+
"1.23.21": [
|
|
10514
|
+
{
|
|
10515
|
+
"id": "1004904",
|
|
10516
|
+
"text": "In Tweak Mode, the shared top bar, navigation, footer and skip link no longer clutter every page; each is now its own page to review once."
|
|
10517
|
+
}
|
|
10506
10518
|
]
|
|
10507
10519
|
}
|
|
@@ -62,6 +62,7 @@ const ci = require('./copy-inventory.js');
|
|
|
62
62
|
const proposals = require('../../modules/copy-desk/proposals.js');
|
|
63
63
|
// The page tweak format's one reader and writer (ADR 0341 D4), for --batch.
|
|
64
64
|
const pages = require('../../modules/copy-desk/pages.js');
|
|
65
|
+
const chromePages = require('../../modules/copy-desk/chrome-pages.js');
|
|
65
66
|
const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
|
|
66
67
|
|
|
67
68
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -559,7 +560,8 @@ function rereadStalePages({ spawn = spawnSync } = {}) {
|
|
|
559
560
|
function readingFor(pageId, { readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
|
|
560
561
|
try {
|
|
561
562
|
const doc = JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'page-readings.json')));
|
|
562
|
-
|
|
563
|
+
// A shared-chrome page (task 1004904) exists only in the derived reading.
|
|
564
|
+
return (chromePages.splitReadings(doc).readings.pages || []).find((p) => p.id === pageId) || null;
|
|
563
565
|
} catch { return null; }
|
|
564
566
|
}
|
|
565
567
|
|
|
@@ -79,6 +79,10 @@ const INTEGRATION = new Set([
|
|
|
79
79
|
// keeps the week's baseline rule, the year counts from a row's first snapshot inside
|
|
80
80
|
// it, the pin under a hide and two racing writers. The DB-free sibling is community_crowns.
|
|
81
81
|
'community_crowns_db',
|
|
82
|
+
// task 1002983: the rising builder and the projects on the move against real SQL — the
|
|
83
|
+
// year's baseline over 30 days, a quiet row's live total, hidden and private left out.
|
|
84
|
+
// The DB-free sibling is community_movers.
|
|
85
|
+
'community_movers_db',
|
|
82
86
|
// bfg, bfg_write, memory_isolation, memory_pull and setup_smoke LEFT this set under task
|
|
83
87
|
// 1004865: none of them ever needed a database. Each failed on a stale test harness (a fake
|
|
84
88
|
// server without res.fail, a stub on the wrong module, an old error shape, a renamed session
|
|
@@ -87,10 +87,17 @@ function slotFilesIn(dir, { exists = (p) => fs.existsSync(p) } = {}) {
|
|
|
87
87
|
|
|
88
88
|
// ---- the browser half --------------------------------------------------------
|
|
89
89
|
|
|
90
|
+
// The inventory as Tweak Mode sees it, shared-chrome pages included (task 1004904):
|
|
91
|
+
// the committed file does not list them, so planning from it alone is unknown_page.
|
|
92
|
+
function derivedInventory() {
|
|
93
|
+
const r = require('../../modules/copy-desk/page-data.js').loadInventory();
|
|
94
|
+
return r.ok ? r.data : { pages: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
90
97
|
async function shoot({ pageId, phase, out }) {
|
|
91
98
|
const reader = require('./page-reader.js');
|
|
92
99
|
const kit = require('../../modules/ui-design/kit/lib');
|
|
93
|
-
const inventory =
|
|
100
|
+
const inventory = derivedInventory();
|
|
94
101
|
const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
|
|
95
102
|
if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
|
|
96
103
|
const { page, plan } = planned;
|
|
@@ -172,7 +179,7 @@ async function main(args) {
|
|
|
172
179
|
if (!pageId) { console.error('usage: node scripts/bongos/tweak-renders.js shoot --page <id> --phase before|after --out <dir>'); return 2; }
|
|
173
180
|
if (cmd === 'plan') {
|
|
174
181
|
const reader = require('./page-reader.js');
|
|
175
|
-
const inventory =
|
|
182
|
+
const inventory = derivedInventory();
|
|
176
183
|
const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
|
|
177
184
|
if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
|
|
178
185
|
for (const phase of taskVisuals.TWEAK_RENDER_PHASES) for (const s of shotsFor(phase, planned.plan)) console.log(`${s.slot}\t${s.width}px\t${s.renderMode}\t${planned.plan.url}`);
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// tests/community_movers.mjs — the rising builder and the projects on the move behind the
|
|
2
|
+
// row under the crowns on the hub's /community home (task 1002983).
|
|
3
|
+
//
|
|
4
|
+
// DB-free. A fake pool answers each statement by its shape. Which snapshots open and close
|
|
5
|
+
// the 30 days, and the baseline a quiet row brings, need a real Postgres:
|
|
6
|
+
// tests/community_movers_db.mjs.
|
|
7
|
+
//
|
|
8
|
+
// What this pins:
|
|
9
|
+
// 1. the statement: the crowns' period rows by the year's rule over the last 30 days, the
|
|
10
|
+
// numbers predicate, and a quiet row's live total as its baseline.
|
|
11
|
+
// 2. the rising builder: under RISING_BASE_MAX before, at least one work, the crowns' tie
|
|
12
|
+
// order; a builder who may not be shown is not counted.
|
|
13
|
+
// 3. the projects: named only on the map with the Public door, ranked by works then
|
|
14
|
+
// credits, at most PROJECTS_MAX, counting only builders who may be shown.
|
|
15
|
+
// 4. the memo: the 30 days computed once per MOVERS_TTL_MS, who and which projects asked
|
|
16
|
+
// on every read.
|
|
17
|
+
// 5. the route: no-store, the same body with or without credentials, the 500 envelope,
|
|
18
|
+
// and its own rate-limit scope.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/community_movers.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const express = require('express');
|
|
28
|
+
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let failed = 0;
|
|
31
|
+
async function t(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { rollupVisibleSql } = require('../modules/platform-identity/account-visibility.js');
|
|
37
|
+
const { onMapSql } = require('../modules/platform-identity/projects-feed.js');
|
|
38
|
+
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
39
|
+
const NUMBERS = flat(rollupVisibleSql('pa'));
|
|
40
|
+
const ON_MAP = flat(onMapSql('p'));
|
|
41
|
+
|
|
42
|
+
// ---- fake engine ------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const isRows = (sql) => /^WITH act AS/.test(sql) && /LEFT JOIN per_project pp/.test(sql);
|
|
45
|
+
const isWho = (sql) => /FROM platform_identity_accounts pa WHERE pa\.github_id = ANY\(\$1::bigint\[\]\)/.test(sql);
|
|
46
|
+
const isNamed = (sql) => /FROM platform_identity_sso_clients c JOIN platform_identity_projects p/.test(sql);
|
|
47
|
+
|
|
48
|
+
const engine = {
|
|
49
|
+
calls: [],
|
|
50
|
+
rows: [], // the 30 days' statement's rows
|
|
51
|
+
shown: new Set(), // github_ids the numbers predicate admits
|
|
52
|
+
named: {}, // client_id → { name, origin } on the map with the Public door
|
|
53
|
+
throwOn: null,
|
|
54
|
+
query: async (text, params = []) => {
|
|
55
|
+
const sql = flat(String(text));
|
|
56
|
+
engine.calls.push({ sql, params });
|
|
57
|
+
if (engine.throwOn && engine.throwOn(sql)) { engine.throwOn = null; throw new Error('boom'); }
|
|
58
|
+
if (isRows(sql)) return { rows: engine.rows };
|
|
59
|
+
if (isWho(sql)) {
|
|
60
|
+
const rows = params[0].filter((id) => engine.shown.has(id))
|
|
61
|
+
.map((id) => ({ github_id: id, handle: `h${id}`, github_login: `l${id}`, display_name: `D${id}`, avatar_url: null }));
|
|
62
|
+
return { rows };
|
|
63
|
+
}
|
|
64
|
+
if (isNamed(sql)) {
|
|
65
|
+
const rows = params[0].filter((c) => engine.named[c]).map((c) => ({ client_id: c, ...engine.named[c] }));
|
|
66
|
+
return { rows };
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`fake pool: unexpected statement ${sql.slice(0, 80)}`);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
function reset() {
|
|
72
|
+
Object.assign(engine, { calls: [], rows: [], shown: new Set(), named: {}, throwOn: null });
|
|
73
|
+
movers.clearMoversMemo();
|
|
74
|
+
}
|
|
75
|
+
const callsOf = (pred) => engine.calls.filter((c) => pred(c.sql));
|
|
76
|
+
// One (project, builder) row as the statement returns it: deltas null for a quiet row.
|
|
77
|
+
const r = (client, id, credits, ships, base, karma = 0) => ({
|
|
78
|
+
client_id: client, github_id: id, credits: credits == null ? null : String(credits),
|
|
79
|
+
tasks_shipped: ships == null ? null : String(ships), karma: credits == null ? null : String(karma), base_credits: String(base),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Pin the module doorway (ADR 0083) BEFORE the modules load.
|
|
83
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
84
|
+
require.cache[moduleApiPath] = {
|
|
85
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
86
|
+
exports: {
|
|
87
|
+
pool: engine,
|
|
88
|
+
corsPublicGet: () => (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); },
|
|
89
|
+
logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const movers = require('../modules/platform-identity/community-movers.js');
|
|
94
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
95
|
+
const buildRoutes = require('../modules/platform-identity/routes/community-leaderboard.js');
|
|
96
|
+
|
|
97
|
+
const NOW = '2026-10-28T09:30:00.000Z';
|
|
98
|
+
const read = (now = NOW) => movers.readMovers({ now: new Date(now), pool: engine });
|
|
99
|
+
|
|
100
|
+
// ---- 1. the statement ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
await t('the 30 days: the crowns’ period rows by the year’s rule, the numbers predicate, a quiet row’s live total as its baseline', async () => {
|
|
103
|
+
reset();
|
|
104
|
+
const out = await read();
|
|
105
|
+
const [s] = callsOf(isRows);
|
|
106
|
+
assert.ok(s.sql.includes(NUMBERS), 'the leaderboard’s numbers predicate');
|
|
107
|
+
assert.match(s.sql, /COALESCE\(e\.credits, 0\)::bigint - COALESCE\(b\.credits, CASE WHEN act\.first_seen_at >= \$1::timestamptz THEN 0 ELSE f\.credits END, 0\) AS credits/, 'the year’s baseline: never a lifetime');
|
|
108
|
+
assert.match(s.sql, /WHERE b\.credits IS NOT NULL OR act\.first_seen_at >= \$1::timestamptz OR f\.credits IS NOT NULL \)/);
|
|
109
|
+
assert.match(s.sql, /COALESCE\(pp\.base_credits, a\.credits::bigint\) AS base_credits/, 'a row with no history in the 30 days brings its live total');
|
|
110
|
+
assert.match(s.sql, /LEFT JOIN per_project pp ON pp\.client_id = act\.client_id AND pp\.github_id = act\.github_id$/);
|
|
111
|
+
assert.deepEqual(s.params.map((d) => d.toISOString()), ['2026-09-28T09:30:00.000Z', NOW], 'rolling: the 30 days up to now');
|
|
112
|
+
assert.deepEqual(out, { window: { start: '2026-09-28T09:30:00.000Z', end: NOW }, rising: null, projects: [] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---- 2. the rising builder ----------------------------------------------------
|
|
116
|
+
|
|
117
|
+
await t('the rising builder: most credits among those under the base cap with a shipped work', async () => {
|
|
118
|
+
reset();
|
|
119
|
+
engine.rows = [
|
|
120
|
+
r('p1', '1', 900, 4, 500), // a veteran: over the cap
|
|
121
|
+
r('p1', '2', 300, 2, movers.RISING_BASE_MAX - 1),
|
|
122
|
+
r('p1', '3', 400, 0, 0), // no work shipped
|
|
123
|
+
r('p1', '4', 250, 1, 0),
|
|
124
|
+
];
|
|
125
|
+
engine.shown = new Set(['1', '2', '3', '4']);
|
|
126
|
+
const { rising } = await read();
|
|
127
|
+
assert.deepEqual(rising, { handle: 'h2', github_login: 'l2', display_name: 'D2', avatar_url: null, credits: 300, tasks_shipped: 2 });
|
|
128
|
+
assert.doesNotMatch(JSON.stringify(rising), /github_id|karma/, 'the crown’s fields and no more');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await t('the base is summed across every project, a quiet one included, and exactly the cap is over it', async () => {
|
|
132
|
+
reset();
|
|
133
|
+
engine.rows = [
|
|
134
|
+
r('p1', '2', 300, 2, 40),
|
|
135
|
+
r('p2', '2', null, null, 60), // quiet: its live total is its baseline
|
|
136
|
+
r('p1', '4', 250, 1, 0),
|
|
137
|
+
];
|
|
138
|
+
engine.shown = new Set(['2', '4']);
|
|
139
|
+
assert.equal((await read()).rising.handle, 'h4', '40 + 60 is not under 100');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('ties go to works shipped, then karma, then handle', async () => {
|
|
143
|
+
reset();
|
|
144
|
+
engine.rows = [r('p1', '5', 200, 1, 0, 9), r('p1', '6', 200, 2, 0, 0), r('p1', '7', 200, 2, 0, 1)];
|
|
145
|
+
engine.shown = new Set(['5', '6', '7']);
|
|
146
|
+
assert.equal((await read()).rising.handle, 'h7');
|
|
147
|
+
reset();
|
|
148
|
+
engine.rows = [r('p1', '8', 200, 2, 0, 1), r('p1', '7', 200, 2, 0, 1)];
|
|
149
|
+
engine.shown = new Set(['7', '8']);
|
|
150
|
+
assert.equal((await read()).rising.handle, 'h7', 'handle, by code unit');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('a builder who may not be shown is not the rising builder, and nobody takes it in their place unless they qualify', async () => {
|
|
154
|
+
reset();
|
|
155
|
+
engine.rows = [r('p1', '2', 300, 2, 0), r('p1', '4', 250, 1, 0)];
|
|
156
|
+
engine.shown = new Set(['4']);
|
|
157
|
+
assert.equal((await read()).rising.handle, 'h4');
|
|
158
|
+
const [who] = callsOf(isWho);
|
|
159
|
+
assert.ok(who.sql.includes(NUMBERS), 'who is shown: the numbers predicate');
|
|
160
|
+
assert.deepEqual(who.params[0].sort(), ['2', '4']);
|
|
161
|
+
reset();
|
|
162
|
+
engine.rows = [r('p1', '2', 300, 2, 0)];
|
|
163
|
+
assert.equal((await read()).rising, null);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---- 3. the projects on the move ------------------------------------------------
|
|
167
|
+
|
|
168
|
+
await t('the projects: named only on the map with the Public door, by works then credits, at most PROJECTS_MAX', async () => {
|
|
169
|
+
reset();
|
|
170
|
+
engine.shown = new Set(['1', '2', '3']);
|
|
171
|
+
engine.rows = [
|
|
172
|
+
r('a', '1', 100, 3, 0), r('a', '2', 50, 1, 0), // 4 works, 150
|
|
173
|
+
r('b', '1', 900, 4, 0), // 4 works, 900
|
|
174
|
+
r('c', '3', 10, 1, 0),
|
|
175
|
+
r('d', '3', 10, 1, 0),
|
|
176
|
+
r('e', '3', 20, 1, 0),
|
|
177
|
+
r('f', '3', 5, 1, 0),
|
|
178
|
+
r('g', '3', 0, 0, 0), // nothing shipped
|
|
179
|
+
r('hidden', '3', 999, 9, 0), // private or stealth: not named
|
|
180
|
+
];
|
|
181
|
+
for (const c of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) engine.named[c] = { name: c.toUpperCase(), origin: `https://${c}.example` };
|
|
182
|
+
const { projects } = await read();
|
|
183
|
+
assert.deepEqual(projects.map((p) => p.name), ['B', 'A', 'E', 'C', 'D'], 'works, then credits, then name');
|
|
184
|
+
assert.equal(projects.length, movers.PROJECTS_MAX);
|
|
185
|
+
assert.deepEqual(projects[1], { name: 'A', origin: 'https://a.example', tasks_shipped: 4, credits: 150 });
|
|
186
|
+
const [named] = callsOf(isNamed);
|
|
187
|
+
assert.ok(named.sql.includes(`${ON_MAP} AND p.visibility = 'public'`), 'a profile’s naming rule (task 1003558)');
|
|
188
|
+
assert.doesNotMatch(JSON.stringify(projects), /client_id|github_id|"hidden"/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await t('a project counts only the builders who may be shown', async () => {
|
|
192
|
+
reset();
|
|
193
|
+
engine.shown = new Set(['1']);
|
|
194
|
+
engine.rows = [r('a', '1', 100, 1, 0), r('a', '2', 500, 5, 0), r('b', '2', 900, 9, 0)];
|
|
195
|
+
engine.named = { a: { name: 'A', origin: 'https://a.example' }, b: { name: 'B', origin: 'https://b.example' } };
|
|
196
|
+
const { projects } = await read();
|
|
197
|
+
assert.deepEqual(projects, [{ name: 'A', origin: 'https://a.example', tasks_shipped: 1, credits: 100 }]);
|
|
198
|
+
assert.deepEqual(callsOf(isNamed)[0].params[0], ['a'], 'a project only a hidden builder moved is not even asked about');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---- 4. the memo ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
await t('the 30 days are computed once per MOVERS_TTL_MS; who and which projects are asked every read', async () => {
|
|
204
|
+
reset();
|
|
205
|
+
engine.rows = [r('a', '2', 300, 2, 0)];
|
|
206
|
+
engine.shown = new Set(['2']);
|
|
207
|
+
engine.named = { a: { name: 'A', origin: 'https://a.example' } };
|
|
208
|
+
const t0 = Date.parse(NOW);
|
|
209
|
+
assert.equal((await read(t0)).rising.handle, 'h2');
|
|
210
|
+
engine.shown = new Set(); // the builder hides
|
|
211
|
+
delete engine.named.a; // and the project closes its door
|
|
212
|
+
const hid = await read(t0 + movers.MOVERS_TTL_MS - 1);
|
|
213
|
+
assert.equal(hid.rising, null, 'a hide beats the memo');
|
|
214
|
+
assert.deepEqual(hid.projects, [], 'so does a closed door');
|
|
215
|
+
assert.equal(hid.window.end, NOW, 'inside the memo: the window it was computed for');
|
|
216
|
+
assert.equal(callsOf(isRows).length, 1);
|
|
217
|
+
assert.equal(callsOf(isWho).length, 2);
|
|
218
|
+
await read(t0 + movers.MOVERS_TTL_MS);
|
|
219
|
+
assert.equal(callsOf(isRows).length, 2, 'stale: computed again');
|
|
220
|
+
await read(t0 - 1000);
|
|
221
|
+
assert.equal(callsOf(isRows).length, 3, 'a clock that runs backwards is never served from the memo');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---- 5. the route -----------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
function serve() {
|
|
227
|
+
const app = express();
|
|
228
|
+
app.use(attachFail);
|
|
229
|
+
app.use('/api/bongos', buildRoutes());
|
|
230
|
+
const server = app.listen(0, '127.0.0.1');
|
|
231
|
+
return new Promise((resolve) => server.once('listening', () => resolve(server)));
|
|
232
|
+
}
|
|
233
|
+
function get(server, path_, headers = {}) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
http.get({ host: '127.0.0.1', port: server.address().port, path: path_, headers }, (res) => {
|
|
236
|
+
let body = '';
|
|
237
|
+
res.on('data', (c) => (body += c));
|
|
238
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, json: body ? JSON.parse(body) : null }));
|
|
239
|
+
}).on('error', reject);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const server = await serve();
|
|
244
|
+
const URL_ = '/api/bongos/community/movers';
|
|
245
|
+
|
|
246
|
+
await t('GET /community/movers: the window, the rising builder and the projects; no-store; CORS-open; the same for every viewer', async () => {
|
|
247
|
+
reset();
|
|
248
|
+
engine.rows = [r('a', '2', 300, 2, 0)];
|
|
249
|
+
engine.shown = new Set(['2']);
|
|
250
|
+
engine.named = { a: { name: 'A', origin: 'https://a.example' } };
|
|
251
|
+
const anon = await get(server, URL_);
|
|
252
|
+
assert.equal(anon.status, 200);
|
|
253
|
+
assert.equal(anon.headers['cache-control'], 'no-store');
|
|
254
|
+
assert.equal(anon.headers['access-control-allow-origin'], '*');
|
|
255
|
+
assert.deepEqual(Object.keys(anon.json), ['window', 'rising', 'projects']);
|
|
256
|
+
assert.equal(anon.json.rising.handle, 'h2');
|
|
257
|
+
assert.equal(anon.json.projects[0].name, 'A');
|
|
258
|
+
assert.doesNotMatch(anon.body, /github_id|client_id/);
|
|
259
|
+
const signedIn = await get(server, URL_, { Cookie: 'bongos_session=x', Authorization: 'Bearer y' });
|
|
260
|
+
assert.equal(signedIn.body, anon.body, 'the route reads no session');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await t('a failure is the 500 envelope, never a stack', async () => {
|
|
264
|
+
reset();
|
|
265
|
+
engine.throwOn = isRows;
|
|
266
|
+
const res = await get(server, URL_);
|
|
267
|
+
assert.equal(res.status, 500);
|
|
268
|
+
assert.equal(res.json.error.code, 'community_movers_failed');
|
|
269
|
+
assert.doesNotMatch(res.body, /boom/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await t('its own rate-limit budget, not the board’s, the week’s or the crowns’', () => {
|
|
273
|
+
const { _internals } = buildRoutes;
|
|
274
|
+
assert.equal(_internals.MOVERS_READ_RATE_SCOPE, 'community-movers-read');
|
|
275
|
+
assert.equal(new Set([_internals.MOVERS_READ_RATE_SCOPE, _internals.CROWNS_READ_RATE_SCOPE, _internals.READ_RATE_SCOPE, _internals.WEEK_READ_RATE_SCOPE]).size, 4);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
server.close();
|
|
279
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
280
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tests/community_movers_db.mjs — task 1002983 (the rising builder and the projects on the
|
|
2
|
+
// move; real DB).
|
|
3
|
+
//
|
|
4
|
+
// Proves against real SQL what a fake pool cannot (tests/community_movers.mjs): the 30 days
|
|
5
|
+
// keep the year's baseline rule (a lifetime brought into them counts from its first snapshot
|
|
6
|
+
// inside, a row first seen inside from zero), a quiet row's live total counts toward the
|
|
7
|
+
// "under 100 before" test, a hidden builder counts nowhere, and a private project is never
|
|
8
|
+
// named, however much it shipped.
|
|
9
|
+
//
|
|
10
|
+
// The read is global (every eligible builder on the hub), so the fixture's 30 days end in
|
|
11
|
+
// March 2099: a shared database's real builders have no snapshot there, so they ship nothing
|
|
12
|
+
// in them and the shipped-work floor keeps them out.
|
|
13
|
+
//
|
|
14
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the INTEGRATION set
|
|
15
|
+
// (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
|
|
16
|
+
//
|
|
17
|
+
// Run: DATABASE_URL=... node tests/community_movers_db.mjs
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { Pool } = require('pg');
|
|
26
|
+
const { readMovers, clearMoversMemo } = require('../modules/platform-identity/community-movers.js');
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
30
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
31
|
+
|
|
32
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
33
|
+
try {
|
|
34
|
+
await pool.query('SELECT 1');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.log(`community_movers_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
37
|
+
await pool.end().catch(() => {});
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const R = 90298301, V = 90298302, Q = 90298303, L = 90298304, N = 90298305, S = 90298306, HIDDEN = 90298307;
|
|
42
|
+
const IDS = [R, V, Q, L, N, S, HIDDEN];
|
|
43
|
+
const P1 = 't1002983-p1', P2 = 't1002983-p2', P3 = 't1002983-p3', PRIV = 't1002983-priv';
|
|
44
|
+
const CLIENTS = [P1, P2, P3, PRIV];
|
|
45
|
+
const origin = (c) => `https://${c}.example`;
|
|
46
|
+
const NOW = new Date('2099-03-12T10:00:00.000Z'); // the 30 days open 2099-02-10T10:00Z
|
|
47
|
+
const OLD = new Date('2098-06-01T00:00:00Z');
|
|
48
|
+
const d = (iso) => new Date(iso);
|
|
49
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
50
|
+
|
|
51
|
+
async function cleanup() {
|
|
52
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
53
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
54
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
55
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [CLIENTS.map(origin)]).catch(() => {});
|
|
56
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE client_id = ANY($1::text[])`, [CLIENTS]).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
async function account(id, handle, { hide = false } = {}) {
|
|
59
|
+
await q(
|
|
60
|
+
`INSERT INTO platform_identity_accounts
|
|
61
|
+
(github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, terms_accepted_at)
|
|
62
|
+
VALUES ($1, $2, $2, $2, 'active', 'public', $3, now())`,
|
|
63
|
+
[id, handle, hide],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
async function project(client, name, visibility) {
|
|
67
|
+
await q(`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin) VALUES ($1, 'x', $2, $3)`, [client, name, origin(client)]);
|
|
68
|
+
await q(
|
|
69
|
+
`INSERT INTO platform_identity_projects (origin, name, description, featured, status, visibility, visibility_source)
|
|
70
|
+
VALUES ($1, $2, 'described', false, 'active', $3, 'control-plane')`,
|
|
71
|
+
[origin(client), name, visibility],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
// One (project, builder) row: its live totals, when the hub first saw it, and its snapshots.
|
|
75
|
+
async function row(client, id, live, firstSeen, history) {
|
|
76
|
+
await q(
|
|
77
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, first_seen_at) VALUES ($1, $2, $3, 0, 0, $4)`,
|
|
78
|
+
[client, id, live, firstSeen],
|
|
79
|
+
);
|
|
80
|
+
for (const [when, credits, ships] of history) {
|
|
81
|
+
await q(
|
|
82
|
+
`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, $3, $4, 0, $5)`,
|
|
83
|
+
[client, id, credits, ships, d(when)],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const read = () => { clearMoversMemo(); return readMovers({ now: NOW, pool }); };
|
|
88
|
+
const who = (w) => w && [w.handle, w.credits, w.tasks_shipped];
|
|
89
|
+
const strip = (ps) => ps.map((p) => [p.name, p.tasks_shipped, p.credits]);
|
|
90
|
+
|
|
91
|
+
let pass = 0;
|
|
92
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
for (const f of [
|
|
96
|
+
'platform_identity_001_tables.sql', 'platform_identity_004_projects.sql', 'platform_identity_005_builder_activity.sql',
|
|
97
|
+
'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
|
|
98
|
+
'platform_identity_011_project_visibility.sql', 'platform_identity_012_account_privacy.sql',
|
|
99
|
+
'platform_identity_019_terms_acceptance.sql', 'platform_identity_020_visibility_pull.sql',
|
|
100
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_032_weekly_features.sql',
|
|
101
|
+
]) await q(migrationSql(f));
|
|
102
|
+
await cleanup();
|
|
103
|
+
|
|
104
|
+
for (const [id, h] of [[R, 'r'], [V, 'v'], [Q, 'q'], [L, 'l'], [N, 'n'], [S, 's']]) await account(id, `t1002983-${h}`);
|
|
105
|
+
await account(HIDDEN, 't1002983-hidden', { hide: true });
|
|
106
|
+
await project(P1, 'Alpha', 'public');
|
|
107
|
+
await project(P2, 'Beta', 'public');
|
|
108
|
+
await project(PRIV, 'Shut', 'private');
|
|
109
|
+
|
|
110
|
+
// R: 40 before the 30 days, then 300 and two works in them — the rising builder.
|
|
111
|
+
await row(P1, R, 340, OLD, [['2099-02-01T00:00:00Z', 40, 0], ['2099-03-01T00:00:00Z', 340, 2]]);
|
|
112
|
+
// V: a veteran — 5000 before, 900 and four works in them.
|
|
113
|
+
await row(P1, V, 5900, OLD, [['2099-02-01T00:00:00Z', 5000, 50], ['2099-03-01T00:00:00Z', 5900, 54]]);
|
|
114
|
+
// Q: 400 and three works on a project first seen inside (from zero), but 800 on a project
|
|
115
|
+
// with no history in the 30 days at all, so Q had 800 before them and is not rising.
|
|
116
|
+
await row(P2, Q, 400, d('2099-02-14T00:00:00Z'), [['2099-02-15T00:00:00Z', 400, 3]]);
|
|
117
|
+
await row(P3, Q, 800, OLD, []);
|
|
118
|
+
// L: a lifetime brought into the 30 days — no snapshot before them, seen before
|
|
119
|
+
// first_seen_at existed. Counted from its first snapshot inside: 150 and a work, never 90150.
|
|
120
|
+
await row(P1, L, 90150, null, [['2099-02-20T00:00:00Z', 90000, 40], ['2099-03-02T00:00:00Z', 90150, 41]]);
|
|
121
|
+
// N: first seen inside, so from zero: 250 and a work.
|
|
122
|
+
await row(P2, N, 250, d('2099-02-12T00:00:00Z'), [['2099-02-12T00:00:00Z', 250, 1]]);
|
|
123
|
+
// S: seven works on the private project.
|
|
124
|
+
await row(PRIV, S, 70, d('2099-02-11T00:00:00Z'), [['2099-02-11T00:00:00Z', 70, 7]]);
|
|
125
|
+
// HIDDEN out-earns and out-ships everyone on Alpha, and the leaderboard may not show them.
|
|
126
|
+
await row(P1, HIDDEN, 9000, OLD, [['2099-02-01T00:00:00Z', 0, 0], ['2099-03-01T00:00:00Z', 9000, 9]]);
|
|
127
|
+
|
|
128
|
+
const first = await read();
|
|
129
|
+
same([first.window.start, first.window.end], ['2099-02-10T10:00:00.000Z', '2099-03-12T10:00:00.000Z'], 'the 30 days up to now');
|
|
130
|
+
same(who(first.rising), ['t1002983-r', 300, 2], 'rising: R’s 300 — V, Q and L had 100 or more before (Q by a quiet project, L by its first snapshot), and hidden counts nowhere');
|
|
131
|
+
same(strip(first.projects), [['Alpha', 7, 1350], ['Beta', 4, 650]],'projects: works then credits; L counted from its first snapshot, hidden not counted, the private project never named');
|
|
132
|
+
same(first.projects[0].origin, origin(P1), 'a project links to its own address');
|
|
133
|
+
same(JSON.stringify(first).match(/github_id|client_id/), null, 'github_id and client_id never cross');
|
|
134
|
+
|
|
135
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [R]);
|
|
136
|
+
const hid = await readMovers({ now: NOW, pool }); // inside the memo
|
|
137
|
+
same(who(hid.rising), ['t1002983-n', 250, 1], 'R hides: the next one under the cap rises, without waiting for the memo');
|
|
138
|
+
same(strip(hid.projects), [['Alpha', 5, 1050], ['Beta', 4, 650]], 'and R’s numbers leave Alpha too');
|
|
139
|
+
|
|
140
|
+
await q(`UPDATE platform_identity_projects SET visibility = 'stealth' WHERE origin = $1`, [origin(P1)]);
|
|
141
|
+
same(strip((await readMovers({ now: NOW, pool })).projects), [['Beta', 4, 650]],'a project that goes stealth leaves the strip on the next read');
|
|
142
|
+
|
|
143
|
+
console.log(`\ncommunity_movers_db.mjs: ${pass} passed`);
|
|
144
|
+
} finally {
|
|
145
|
+
await cleanup();
|
|
146
|
+
await pool.end();
|
|
147
|
+
}
|
|
@@ -75,12 +75,12 @@ const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
|
75
75
|
|
|
76
76
|
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
77
77
|
const STUDIO_LINES = [
|
|
78
|
-
L('L0001', '
|
|
79
|
-
L('L0002', '
|
|
78
|
+
L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
79
|
+
L('L0002', 'shell bar', 'Ctrl K', 'shared'),
|
|
80
80
|
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
81
81
|
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
82
82
|
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
83
|
-
L('L0006', '
|
|
83
|
+
L('L0006', 'shell foot', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
|
|
84
84
|
];
|
|
85
85
|
const INDEX_LINES = [
|
|
86
86
|
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
@@ -64,11 +64,11 @@ const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
|
64
64
|
|
|
65
65
|
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
66
66
|
const STUDIO_LINES = [
|
|
67
|
-
L('L0001', '
|
|
68
|
-
L('L0002', '
|
|
67
|
+
L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
68
|
+
L('L0002', 'shell bar', 'Ctrl K', 'shared'),
|
|
69
69
|
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
70
70
|
L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
71
|
-
L('L0005', '
|
|
71
|
+
L('L0005', 'shell foot', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0005' }),
|
|
72
72
|
];
|
|
73
73
|
const readingsWith = (studioHash, lines = STUDIO_LINES) => ({
|
|
74
74
|
schema: 1,
|
|
@@ -96,13 +96,13 @@ try {
|
|
|
96
96
|
assert.equal(r.body.page.surface_word, 'builders hall');
|
|
97
97
|
assert.equal(r.body.reading.reading_hash, 'studio-h1');
|
|
98
98
|
assert.deepEqual(r.body.reading.lines.map((l) => [l.key, l.section, l.text, l.note]), [
|
|
99
|
-
['L0001', '
|
|
99
|
+
['L0001', 'shell bar', 'Studio', 'shared'],
|
|
100
100
|
// Shared shell text with no registry row cannot be applied mechanically,
|
|
101
101
|
// so it goes to an engineer at submit, and the note says so.
|
|
102
|
-
['L0002', '
|
|
102
|
+
['L0002', 'shell bar', 'Ctrl K', 'engineer'],
|
|
103
103
|
['L0003', 'greeting', 'Welcome back', null],
|
|
104
104
|
['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
|
|
105
|
-
['L0005', '
|
|
105
|
+
['L0005', 'shell foot', 'Resume where you left off', null],
|
|
106
106
|
]);
|
|
107
107
|
assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
|
|
108
108
|
assert.equal(r.body.round, null);
|
|
@@ -101,7 +101,8 @@ try {
|
|
|
101
101
|
|
|
102
102
|
const { status, body } = await getNext(ALICE);
|
|
103
103
|
assert.equal(status, 200, JSON.stringify(body));
|
|
104
|
-
assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index']);
|
|
104
|
+
assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index', 'landing:skip-link']);
|
|
105
|
+
// landing:skip-link is the surface's shared chrome, a page of its own, untweaked
|
|
105
106
|
assert.equal(body.recommended.why, '2 people asked for it');
|
|
106
107
|
assert.equal(body.recommended.why_line, 'landing · 2 people asked for it · untweaked');
|
|
107
108
|
assert.equal(body.alternatives[1].why, '1 line changed since your last pass', 'the round artist is the web claim, alice');
|
|
@@ -145,7 +146,7 @@ try {
|
|
|
145
146
|
assert.equal(take.body.page_id, 'status:explore');
|
|
146
147
|
const after = (await getNext(CAROL)).body;
|
|
147
148
|
assert.deepEqual(after.skipped.map((s) => s.page_id).sort(), ['landing:projects', 'status:explore']);
|
|
148
|
-
assert.deepEqual(order(after), ['builders:studio', 'landing:index']);
|
|
149
|
+
assert.deepEqual(order(after), ['builders:studio', 'landing:index', 'landing:skip-link', 'builders:top-bar']);
|
|
149
150
|
});
|
|
150
151
|
|
|
151
152
|
await test('a submitted round is in flight: skipped, and the claim agrees', async () => {
|
|
@@ -115,7 +115,8 @@ try {
|
|
|
115
115
|
assert.equal(p('builders:studio').status_label, 'Text tweaked 1x');
|
|
116
116
|
assert.equal(p('builders:studio').drift.changed_count, 1);
|
|
117
117
|
assert.equal(p('builders:studio').open_round.state, 'writing');
|
|
118
|
-
|
|
118
|
+
// Each surface's shared chrome is a page of its own now: landing gains a skip link page, builders a top bar page.
|
|
119
|
+
assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 3 tweaked', '1 of 2 tweaked', '0 of 1 tweaked']);
|
|
119
120
|
assert.deepEqual(body.readings, { ok: true, at: 'docs/page-readings.json' });
|
|
120
121
|
assert.deepEqual(body.inventory, { at: 'docs/page-inventory.json' });
|
|
121
122
|
});
|