@bongos/core 1.23.19 → 1.23.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +62 -32
- 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 +2 -0
- package/docs/page-inventory.json +3 -1
- package/docs/page-readings.json +73 -58
- 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 +6 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/community_movers.mjs +280 -0
- package/tests/community_movers_db.mjs +147 -0
- package/tests/hub_community_page.mjs +78 -9
- package/tests/privacy_matrix.mjs +48 -9
|
@@ -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
|
+
}
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
// its refusals; the recruiter list as a door signed out, a refusal on a 403, and rows
|
|
18
18
|
// only from a 200 — name, face and login, never the ranked half's numbers; the builder
|
|
19
19
|
// of the week as one line on the board and as its own page, drawn as the server sent it;
|
|
20
|
-
// the home's three crowns, its empty and failed states, and its doors
|
|
20
|
+
// the home's three crowns, its empty and failed states, and its doors; the row under
|
|
21
|
+
// them (task 1002983), the rising builder and the projects on the move, from its own read
|
|
21
22
|
// • the Community menu on every page that wears the hub's bar (apex-only where the page
|
|
22
23
|
// also serves a child), folded into the Projects band on phones
|
|
23
24
|
// • the routes: the five paths served on the hub's apex only
|
|
24
|
-
// • the kit: the states files, and the stub answering as the
|
|
25
|
+
// • the kit: the states files, and the stub answering as the six reads do
|
|
25
26
|
//
|
|
26
27
|
// DB-free. Run: node --test tests/hub_community_page.mjs
|
|
27
28
|
|
|
@@ -51,6 +52,7 @@ const SEARCH_JS = fs.readFileSync(path.join(PI, 'builder-search.js'), 'utf8');
|
|
|
51
52
|
const SUBPAGES = [['/community', 'Community home'], ['/community/leaderboard', 'Leaderboard'], ['/community/week', 'Builder of the week'], ['/community/find', 'Find a builder'], ['/community/recruiters', 'Open to recruiters']];
|
|
52
53
|
const WEEK_JS = fs.readFileSync(path.join(PI, 'builder-of-the-week.js'), 'utf8');
|
|
53
54
|
const CROWNS_JS = fs.readFileSync(path.join(PI, 'community-crowns.js'), 'utf8');
|
|
55
|
+
const MOVERS_JS = fs.readFileSync(path.join(PI, 'community-movers.js'), 'utf8');
|
|
54
56
|
|
|
55
57
|
// ── 1. the markup ────────────────────────────────────────────────────────────
|
|
56
58
|
|
|
@@ -119,7 +121,8 @@ test('the home states the month’s and year’s rule, and it is the module’s;
|
|
|
119
121
|
assert.match(rule, /The week and the month are decided once, when they end; the year shows the leader so far, and is decided on 1 January\./);
|
|
120
122
|
assert.match(CROWNS_JS, /ON CONFLICT \(period, period_start\) DO NOTHING/);
|
|
121
123
|
assert.match(CROWNS_JS, /computeWinner\(start, now, pool, \{ fromFirstSnapshot: true \}\)/, 'the year so far is live');
|
|
122
|
-
for (const id of ['homeFailed', 'homeCrowns', 'crownYearLast']) assert.match(home, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
|
|
124
|
+
for (const id of ['homeFailed', 'homeCrowns', 'crownYearLast', 'homeMovers', 'moversRule', 'moveNone']) assert.match(home, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
|
|
125
|
+
assert.ok(home.indexOf('id="homeMovers"') > home.indexOf('id="homeRule"') && home.indexOf('id="homeMovers"') < home.indexOf('id="doorsH"'), 'the row under the crowns and their rule, above the doors (owner decision 5, task 1004902)');
|
|
123
126
|
const doors = [...home.split('<ul class="homeDoors" aria-labelledby="doorsH">')[1].split('</ul>')[0].matchAll(/<li( class="knowCell" hidden)?><a class="btn ghost" href="([^"]+)">([^<]+)<\/a><\/li>/g)];
|
|
124
127
|
assert.deepEqual(doors.map((m) => [m[2], m[3], !!m[1]]), [
|
|
125
128
|
['/community/leaderboard', 'Leaderboard', false], ['/community/week', 'Builder of the week', false], ['/community/find', 'Find a builder', false],
|
|
@@ -127,6 +130,19 @@ test('the home states the month’s and year’s rule, and it is the module’s;
|
|
|
127
130
|
], 'one door per page, the menu’s order; Builders you may know signed in only, like its menu item');
|
|
128
131
|
});
|
|
129
132
|
|
|
133
|
+
test('the movers’ rule is the module’s: the base cap, the shipped-work floor, the projects’ order and who is counted (task 1002983)', () => {
|
|
134
|
+
const rule = MAIN.split('<p class="commNote" id="moversRule" hidden>')[1].split('</p>')[0];
|
|
135
|
+
const cap = MOVERS_JS.match(/const RISING_BASE_MAX = (\d+);/)[1];
|
|
136
|
+
assert.match(rule, new RegExp(`the most \\{\\{currency\\}\\} in the last 30 days, among the builders who had earned under ${cap} \\{\\{currency\\}\\} before them and shipped at least one work in them\\.`));
|
|
137
|
+
assert.match(MOVERS_JS, /const WINDOW = '30d';/);
|
|
138
|
+
assert.match(MOVERS_JS, /s\.base < RISING_BASE_MAX && s\.tasks_shipped >= 1/, 'the cap and the floor the page states are the ones applied');
|
|
139
|
+
assert.match(rule, /Projects on the move are the public projects that shipped the most works in the last 30 days, then earned the most \{\{currency\}\}\./);
|
|
140
|
+
assert.match(MOVERS_JS, /\(b\.tasks_shipped - a\.tasks_shipped\) \|\| \(b\.credits - a\.credits\)/);
|
|
141
|
+
assert.match(MOVERS_JS, /onMapSql\('p'\)\} AND p\.visibility = 'public'/, '“public projects”: on the map with the Public door');
|
|
142
|
+
assert.match(rule, /Builders who hide their numbers or keep their account private are not counted in either\./);
|
|
143
|
+
assert.match(MOVERS_JS, /eligibleIdentities/);
|
|
144
|
+
});
|
|
145
|
+
|
|
130
146
|
test('the window dropdown and the query limits are the routes’ own (boundary-forced copies)', () => {
|
|
131
147
|
const days = BOARD_JS.match(/const WINDOW_DAYS = Object\.freeze\(\{([^}]*)\}\);/);
|
|
132
148
|
const windows = [...days[1].matchAll(/'?(\w+)'?:/g)].map((m) => m[1]);
|
|
@@ -144,12 +160,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
|
|
|
144
160
|
assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
|
|
145
161
|
});
|
|
146
162
|
|
|
147
|
-
test('the script builds no markup from strings, reads only the
|
|
163
|
+
test('the script builds no markup from strings, reads only the seven routes, and never re-ranks or reads the ranked tier', () => {
|
|
148
164
|
assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
|
|
149
|
-
// every path literal in the code: the API base, the
|
|
165
|
+
// every path literal in the code: the API base, the seven reads, the five sub-pages, the
|
|
150
166
|
// sign-in door and the hall's invite box the Recruit door opens (task 1002292)
|
|
151
167
|
const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
|
|
152
|
-
assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/builder-of-the-week', '/community/crowns', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/community/week', '/my-projects', '/scouting']);
|
|
168
|
+
assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/builder-of-the-week', '/community/crowns', '/community/find', '/community/leaderboard', '/community/movers', '/community/recruiters', '/community/search', '/community/week', '/my-projects', '/scouting']);
|
|
153
169
|
assert.doesNotMatch(CODE, /method:/, 'it only reads');
|
|
154
170
|
assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
|
|
155
171
|
assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
|
|
@@ -201,8 +217,14 @@ const CROWNS = () => ({
|
|
|
201
217
|
month: { start: '2026-10-01', end: '2026-11-01', first_crown: null, winner: WINNER('bo', 6120, 1, { display_name: null, avatar_url: 'javascript:alert(1)' }) },
|
|
202
218
|
year: { start: '2026-01-01', since: '2026-09-27', last: null, winner: WINNER('cy', 9870, 31) },
|
|
203
219
|
});
|
|
220
|
+
const MOVERS = () => ({
|
|
221
|
+
window: { start: '2026-09-28T09:30:00.000Z', end: '2026-10-28T09:30:00.000Z' },
|
|
222
|
+
rising: WINNER('kofi', 640, 5, { display_name: 'Kofi <b>M</b>' }),
|
|
223
|
+
projects: [{ name: 'Tide <i>pool</i>', origin: 'https://tidepool.example', tasks_shipped: 23, credits: 1840 }, { name: 'Quill', origin: 'javascript:alert(1)', tasks_shipped: 1, credits: 45 }],
|
|
224
|
+
});
|
|
204
225
|
const READS = () => ({
|
|
205
226
|
'/community/crowns': { status: 200, body: CROWNS() },
|
|
227
|
+
'/community/movers': { status: 200, body: MOVERS() },
|
|
206
228
|
'/community/builder-of-the-week': { status: 200, body: WEEK() },
|
|
207
229
|
'/community/leaderboard?window=all': { status: 200, body: { window: 'all', count: 99, results: [ROW(26, 'ana', 1200), ROW(27, 'bo', 1500, { tasks_shipped: 1, karma: 0, avatar_url: 'javascript:alert(1)', display_name: null })] } },
|
|
208
230
|
'/community/leaderboard?window=30d': { status: 200, body: { window: '30d', results: [ROW(1, 'cy', 40)] } },
|
|
@@ -271,7 +293,7 @@ test('each path shows its one section, marks its menu items current and names it
|
|
|
271
293
|
|
|
272
294
|
test('the home: one read, the three crowns with their periods, names as text, and nothing asked about projects', async () => {
|
|
273
295
|
const ui = await boot({ pathname: '/community' });
|
|
274
|
-
assert.deepEqual(ui.gets(), ['/api/bongos/community/crowns']);
|
|
296
|
+
assert.deepEqual(ui.gets(), ['/api/bongos/community/crowns', '/api/bongos/community/movers']);
|
|
275
297
|
assert.equal(ui.$('homeLoading').hidden, true);
|
|
276
298
|
assert.equal(ui.$('homeFailed').hidden, true);
|
|
277
299
|
assert.equal(ui.$('homeCrowns').hidden, false);
|
|
@@ -286,7 +308,7 @@ test('the home: one read, the three crowns with their periods, names as text, an
|
|
|
286
308
|
assert.equal(find(ui.$('crownYear'), 'mineRow__host').textContent, '@cy');
|
|
287
309
|
assert.equal(ui.$('crownYearLast').hidden, true, 'no year crowned yet');
|
|
288
310
|
const signedIn = await boot({ pathname: '/community', me: { github_login: 'owner' } });
|
|
289
|
-
assert.deepEqual(signedIn.gets(), ['/api/bongos/community/crowns'], 'signed in, the same
|
|
311
|
+
assert.deepEqual(signedIn.gets(), ['/api/bongos/community/crowns', '/api/bongos/community/movers'], 'signed in, the same two reads: no Recruit door here');
|
|
290
312
|
assert.equal(signedIn.$('homeCrowns').textContent, ui.$('homeCrowns').textContent, 'the same crowns for every viewer');
|
|
291
313
|
});
|
|
292
314
|
|
|
@@ -332,6 +354,45 @@ test('the home: a failed read is an alert, never three empty crowns; a rate limi
|
|
|
332
354
|
assert.match((await boot({ pathname: '/community', reads })).$('homeFailed').textContent, /^Too many requests/);
|
|
333
355
|
});
|
|
334
356
|
|
|
357
|
+
test('the row under the crowns: the rising builder as a crown, the projects in the server’s order, names as text, a link only to a web address (task 1002983)', async () => {
|
|
358
|
+
const ui = await boot({ pathname: '/community' });
|
|
359
|
+
assert.equal(ui.$('homeMovers').hidden, false);
|
|
360
|
+
assert.equal(ui.$('moversRule').hidden, false);
|
|
361
|
+
assert.equal(find(ui.$('moveRising'), 'crownName').textContent, 'Kofi <b>M</b>', 'a name is text, never markup');
|
|
362
|
+
assert.equal(find(ui.$('moveRising'), 'crownNums').textContent, '640credits5works shipped');
|
|
363
|
+
const rows = kids(ui.$('moveProjects'));
|
|
364
|
+
assert.deepEqual(rows.map((r) => find(r, 'commPlace').textContent), ['1', '2'], 'positions in the order sent');
|
|
365
|
+
const first = find(rows[0], 'mineRow__name');
|
|
366
|
+
assert.equal(first.tag, 'a');
|
|
367
|
+
assert.equal(first.attrs.href, 'https://tidepool.example/');
|
|
368
|
+
assert.equal(first.textContent, 'Tide <i>pool</i>');
|
|
369
|
+
assert.equal(find(rows[0], 'mineRow__hint').textContent, (1840).toLocaleString() + ' credits earned');
|
|
370
|
+
assert.equal(find(rows[0], 'commCredits').textContent, '23works shipped');
|
|
371
|
+
assert.equal(find(rows[1], 'mineRow__name').tag, 'div', 'an address that is not http(s) is never a link');
|
|
372
|
+
assert.equal(find(rows[1], 'commCredits').textContent, '1work shipped', 'one work, singular');
|
|
373
|
+
assert.equal(ui.$('moveNone').hidden, true);
|
|
374
|
+
const signedIn = await boot({ pathname: '/community', me: { github_login: 'owner' } });
|
|
375
|
+
assert.equal(signedIn.$('homeMovers').textContent, ui.$('homeMovers').textContent, 'the same row for every viewer');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('the row under the crowns: nobody to show is one line in each card; a failed read drops only the row', async () => {
|
|
379
|
+
const reads = READS();
|
|
380
|
+
reads['/community/movers'] = { status: 200, body: { window: {}, rising: null, projects: [] } };
|
|
381
|
+
const quiet = await boot({ pathname: '/community', reads });
|
|
382
|
+
assert.equal(quiet.$('homeMovers').hidden, false);
|
|
383
|
+
assert.equal(find(quiet.$('moveRising'), 'commMsg').textContent, 'No rising builder in the last 30 days.');
|
|
384
|
+
assert.equal(kids(quiet.$('moveProjects')).length, 0);
|
|
385
|
+
assert.equal(quiet.$('moveNone').hidden, false);
|
|
386
|
+
for (const answer of [{ status: 500, body: {} }, { status: 429, body: {} }, { reject: true }]) {
|
|
387
|
+
reads['/community/movers'] = answer;
|
|
388
|
+
const failed = await boot({ pathname: '/community', reads });
|
|
389
|
+
assert.equal(failed.$('homeMovers').hidden, true);
|
|
390
|
+
assert.equal(failed.$('moversRule').hidden, true, 'no rule for cards that are not there');
|
|
391
|
+
assert.equal(failed.$('homeCrowns').hidden, false, 'the crowns stand on their own');
|
|
392
|
+
assert.equal(failed.$('homeFailed').hidden, true, 'and no alert: the row is the home’s extra');
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
335
396
|
test('the leaderboard, signed out: the all-time board in the server’s order with the server’s places, and nothing asked about the viewer', async () => {
|
|
336
397
|
const ui = await boot();
|
|
337
398
|
assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/community/builder-of-the-week']);
|
|
@@ -747,6 +808,8 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
|
|
|
747
808
|
assert.equal(states.states['out-find'].url, '/community/find');
|
|
748
809
|
assert.equal(states.states['out-week'].url, '/community/week');
|
|
749
810
|
assert.ok(states.states['out-board'].expect.visible.includes('#boardCrown'), 'the board carries the crown line');
|
|
811
|
+
assert.ok(states.states.out.expect.visible.includes('#homeMovers'), 'the home carries the row under the crowns (task 1002983)');
|
|
812
|
+
assert.equal(json('community-movers-none.states.json').stub.fixtures['community-movers'], 'community-movers-none');
|
|
750
813
|
// the home's other answers (task 1004902): before the first month, nobody, and a new year
|
|
751
814
|
for (const [file, fixture] of [['community-crowns-first.states.json', 'community-crowns-first'], ['community-crowns-none.states.json', 'community-crowns-none'], ['community-crowns-new-year.states.json', 'community-crowns-new-year']]) {
|
|
752
815
|
assert.equal(json(file).stub.fixtures['community-crowns'], fixture);
|
|
@@ -790,7 +853,7 @@ async function call(origin, p) {
|
|
|
790
853
|
return { status: res.status, body: await res.json() };
|
|
791
854
|
}
|
|
792
855
|
|
|
793
|
-
test('the kit stub answers the crowns, the board, the builder of the week, the search and the recruiting door the way the routes do', async () => {
|
|
856
|
+
test('the kit stub answers the crowns, the movers, the board, the builder of the week, the search and the recruiting door the way the routes do', async () => {
|
|
794
857
|
const s = await stub();
|
|
795
858
|
try {
|
|
796
859
|
const crowns = await call(s.origin, '/community/crowns');
|
|
@@ -798,6 +861,12 @@ test('the kit stub answers the crowns, the board, the builder of the week, the s
|
|
|
798
861
|
const { week: w, month: m, year: y } = crowns.body;
|
|
799
862
|
assert.deepEqual([Object.keys(w).sort(), Object.keys(m).sort(), Object.keys(y).sort()], [['end', 'start', 'winner'], ['end', 'first_crown', 'start', 'winner'], ['last', 'since', 'start', 'winner']], 'community-crowns.js’s shape');
|
|
800
863
|
assert.ok([w.winner, m.winner, y.winner].every((r) => r && Object.keys(r).sort().join() === 'avatar_url,credits,display_name,github_login,handle,tasks_shipped'), 'the module’s projection, no github_id');
|
|
864
|
+
const movers = await call(s.origin, '/community/movers');
|
|
865
|
+
assert.deepEqual(Object.keys(movers.body), ['window', 'rising', 'projects'], 'community-movers.js’s shape');
|
|
866
|
+
assert.equal(Object.keys(movers.body.rising).sort().join(), 'avatar_url,credits,display_name,github_login,handle,tasks_shipped', 'the crown’s projection, no github_id');
|
|
867
|
+
assert.ok(movers.body.projects.length && movers.body.projects.every((p) => Object.keys(p).sort().join() === 'credits,name,origin,tasks_shipped'), 'a project’s name, address and two numbers, no client_id');
|
|
868
|
+
const ships = movers.body.projects.map((p) => p.tasks_shipped);
|
|
869
|
+
assert.deepEqual(ships, [...ships].sort((a, b) => b - a), 'most works first');
|
|
801
870
|
const week = await call(s.origin, '/community/builder-of-the-week');
|
|
802
871
|
assert.deepEqual(Object.keys(week.body).sort(), ['past', 'week', 'winner'], 'the route’s three keys');
|
|
803
872
|
assert.ok(week.body.winner && week.body.past.length, 'a crown and the weeks before it');
|
package/tests/privacy_matrix.mjs
CHANGED
|
@@ -158,7 +158,11 @@ function identitySentinels(acct) {
|
|
|
158
158
|
// the year so far and last year (task 1004902): a crown IS stats, so the board's
|
|
159
159
|
// rule. Each account in turn is made the winner of all three, so the reads'
|
|
160
160
|
// own predicates decide: 'listed' | 'absent'
|
|
161
|
-
//
|
|
161
|
+
// movers GET /community/movers Anon · Builder · Recruiter — the rising builder and the
|
|
162
|
+
// projects on the move (task 1002983): a card IS stats, so the board's rule, and
|
|
163
|
+
// a project's totals are the sum of what may be shown. Each account in turn is
|
|
164
|
+
// the one who moved, so the reads' own predicates decide: 'listed' | 'absent'
|
|
165
|
+
// directory GET /scouting Recruiter — 'full' | 'name-only' | 'absent'
|
|
162
166
|
// sliver GET /scouting/:handle Recruiter — 'full' (D3) | 'name-only' (D3, hidden) | 404
|
|
163
167
|
// self GET /me/cross-project Self — 'own' (own numbers, any flags) | 'n/a' (no session exists)
|
|
164
168
|
// guild GET /guilds/:slug Anon · Builder · Recruiter · Self — the guild page's PUBLIC
|
|
@@ -174,13 +178,13 @@ function identitySentinels(acct) {
|
|
|
174
178
|
// people the guild page already shows them — shown_publicly AND existence;
|
|
175
179
|
// hide_stats never hides membership, and private or provisional is never listed
|
|
176
180
|
const MATRIX = {
|
|
177
|
-
PROV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
|
|
178
|
-
PUB: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
179
|
-
SHY: { profile: 'identity', search: 'name-only', board: 'absent', week: 'absent', crowns: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
180
|
-
PRIV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
181
|
-
PRIVD: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
182
|
-
PRIVDH: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
183
|
-
OPTOUT: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
181
|
+
PROV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
|
|
182
|
+
PUB: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', movers: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
183
|
+
SHY: { profile: 'identity', search: 'name-only', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
184
|
+
PRIV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
185
|
+
PRIVD: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
186
|
+
PRIVDH: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
187
|
+
OPTOUT: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', movers: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
184
188
|
};
|
|
185
189
|
|
|
186
190
|
// ===========================================================================
|
|
@@ -303,6 +307,9 @@ function weekOf(key) {
|
|
|
303
307
|
// THE ACCOUNT that won the month, the year so far and last year (task 1004902); the
|
|
304
308
|
// crowns test makes each fixture account the winner in turn.
|
|
305
309
|
let CROWNED = 'PUB';
|
|
310
|
+
// THE ACCOUNT that moved in the 30 days, on MX_CLIENT's project (task 1002983); the movers
|
|
311
|
+
// test makes each fixture account the one in turn.
|
|
312
|
+
let MOVED = 'PUB';
|
|
306
313
|
|
|
307
314
|
const pool = {
|
|
308
315
|
query: async (text, params = []) => {
|
|
@@ -393,6 +400,20 @@ const pool = {
|
|
|
393
400
|
}));
|
|
394
401
|
return { rows, rowCount: rows.length };
|
|
395
402
|
}
|
|
403
|
+
// THE MOVERS (task 1002983): the 30 days' rows carry the floor on the act CTE, as the
|
|
404
|
+
// year's; MOVED had nothing before them. The project names are MX_CLIENT's.
|
|
405
|
+
if (/^WITH act AS/.test(sql) && /LEFT JOIN per_project pp/.test(sql)) {
|
|
406
|
+
const where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = a\.github_id WHERE (.+?) \), per_project AS/, 'movers');
|
|
407
|
+
const rows = ACCOUNTS.filter((a) => a.key === MOVED).filter(accountFilter(where, [])).map((a) => ({
|
|
408
|
+
client_id: MX_CLIENT.client_id, github_id: a.github_id, credits: String(ROLLUP[a.github_id].credits),
|
|
409
|
+
tasks_shipped: String(ROLLUP[a.github_id].works_shipped), karma: String(ROLLUP[a.github_id].karma), base_credits: '0',
|
|
410
|
+
}));
|
|
411
|
+
return { rows, rowCount: rows.length };
|
|
412
|
+
}
|
|
413
|
+
if (/FROM platform_identity_sso_clients c JOIN platform_identity_projects p ON p\.origin = c\.origin WHERE c\.client_id = ANY\(\$1::text\[\]\)/.test(sql)) {
|
|
414
|
+
const rows = (params[0] || []).includes(MX_CLIENT.client_id) ? [{ client_id: MX_CLIENT.client_id, name: MX_CLIENT.name, origin: MX_CLIENT.origin }] : [];
|
|
415
|
+
return { rows, rowCount: rows.length };
|
|
416
|
+
}
|
|
396
417
|
// ... the year so far: computeWinner's act CTE carries the floor, then the leader's re-read
|
|
397
418
|
if (/^WITH act AS/.test(sql) && /\) f ON true/.test(sql)) {
|
|
398
419
|
const where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = a\.github_id WHERE (.+?) \), per_project AS/, 'year so far');
|
|
@@ -868,6 +889,24 @@ test('the crowns — the month, the year so far and last year only for shown, pu
|
|
|
868
889
|
} finally { CROWNED = 'PUB'; }
|
|
869
890
|
});
|
|
870
891
|
|
|
892
|
+
test('the movers — the rising builder and a project’s totals only from shown, public, active accounts, the same for every viewer', async () => {
|
|
893
|
+
const { clearMoversMemo } = require('../modules/platform-identity/community-movers.js');
|
|
894
|
+
try {
|
|
895
|
+
for (const k of KEYS) {
|
|
896
|
+
MOVED = k;
|
|
897
|
+
clearMoversMemo(); // the 30 days are memoised: a new mover is a new computation
|
|
898
|
+
const seen = [];
|
|
899
|
+
for (const who of [ANON, STRANGER, RECRUITER]) seen.push(await get(who, '/api/bongos/community/movers'));
|
|
900
|
+
for (const r of seen.slice(1)) assert.equal(r.text, seen[0].text, `${r.who} got different movers with ${k} moving`);
|
|
901
|
+
const { rising, projects } = seen[0].json;
|
|
902
|
+
const listed = MATRIX[k].movers === 'listed';
|
|
903
|
+
assert.equal(!!rising && rising.handle === ACCT[k].handle, listed, `${k} as the rising builder: expected ${MATRIX[k].movers}`);
|
|
904
|
+
assert.deepEqual(projects.map((p) => p.tasks_shipped), listed ? [ROLLUP[ACCT[k].github_id].works_shipped] : [], `${k}'s works in a project's totals: expected ${MATRIX[k].movers}`);
|
|
905
|
+
assert.doesNotMatch(seen[0].text, /github_id|client_id/, 'github_id and client_id never cross');
|
|
906
|
+
}
|
|
907
|
+
} finally { MOVED = 'PUB'; }
|
|
908
|
+
});
|
|
909
|
+
|
|
871
910
|
test('the recruiting directory — the recruiter sees the full and name-only tiers; Anon and Builder are refused', async () => {
|
|
872
911
|
const r = await get(RECRUITER, '/api/bongos/scouting');
|
|
873
912
|
assert.equal(r.status, 200);
|
|
@@ -910,7 +949,7 @@ function disclosed(r) {
|
|
|
910
949
|
}
|
|
911
950
|
|
|
912
951
|
test('the walk covered every surface for every row (so the claims below are about something)', () => {
|
|
913
|
-
for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/community/builder-of-the-week', '/community/crowns', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
|
|
952
|
+
for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/community/builder-of-the-week', '/community/crowns', '/community/movers', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
|
|
914
953
|
assert.ok(SEEN.some((r) => r.path.includes(p) && r.status === 200), `no 200 from ${p} — the sweep below would be vacuous for it`);
|
|
915
954
|
}
|
|
916
955
|
assert.ok(SEEN.length >= KEYS.length * 6, `only ${SEEN.length} responses recorded`);
|