@bongos/core 1.23.18 → 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.
Files changed (60) hide show
  1. package/.bongos-core.json +134 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +43 -1
  8. package/docs/api-reference.md +4 -2
  9. package/docs/copy-inventory.md +992 -935
  10. package/docs/copy-registry.json +1897 -1313
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +12 -2
  13. package/docs/page-readings.json +616 -576
  14. package/modules/platform-identity/builder-of-the-week.js +61 -24
  15. package/modules/platform-identity/community-crowns.js +176 -0
  16. package/modules/platform-identity/community-leaderboard.js +4 -1
  17. package/modules/platform-identity/community-movers.js +152 -0
  18. package/modules/platform-identity/migrations/platform_identity_033_period_features.sql +43 -0
  19. package/modules/platform-identity/routes/community-leaderboard.js +45 -3
  20. package/modules/public-landing/public/account.html +3 -2
  21. package/modules/public-landing/public/community-crowns-first.states.json +15 -0
  22. package/modules/public-landing/public/community-crowns-new-year.states.json +15 -0
  23. package/modules/public-landing/public/community-crowns-none.states.json +15 -0
  24. package/modules/public-landing/public/community-empty.states.json +1 -1
  25. package/modules/public-landing/public/community-movers-none.states.json +15 -0
  26. package/modules/public-landing/public/community-owner-two.states.json +2 -2
  27. package/modules/public-landing/public/community-owner.states.json +1 -1
  28. package/modules/public-landing/public/community-week-none.states.json +1 -1
  29. package/modules/public-landing/public/community.html +188 -22
  30. package/modules/public-landing/public/community.probes.json +20 -3
  31. package/modules/public-landing/public/community.states.json +8 -6
  32. package/modules/public-landing/public/connections.html +3 -2
  33. package/modules/public-landing/public/contact.html +3 -2
  34. package/modules/public-landing/public/guild.html +3 -2
  35. package/modules/public-landing/public/index.html +3 -2
  36. package/modules/public-landing/public/invitations.html +3 -2
  37. package/modules/public-landing/public/privacy.html +3 -2
  38. package/modules/public-landing/public/projects.html +3 -2
  39. package/modules/public-landing/public/terms.html +3 -2
  40. package/modules/ui-design/kit/fixtures/community-crowns-first.json +6 -0
  41. package/modules/ui-design/kit/fixtures/community-crowns-new-year.json +6 -0
  42. package/modules/ui-design/kit/fixtures/community-crowns-none.json +6 -0
  43. package/modules/ui-design/kit/fixtures/community-crowns.json +6 -0
  44. package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
  45. package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
  46. package/modules/ui-design/kit/serve.js +22 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +12 -0
  50. package/scripts/bongos/run-unit-tests.js +8 -0
  51. package/src/module-api.js +1 -1
  52. package/src/platform-server.js +4 -3
  53. package/tests/community_crowns.mjs +369 -0
  54. package/tests/community_crowns_db.mjs +164 -0
  55. package/tests/community_movers.mjs +280 -0
  56. package/tests/community_movers_db.mjs +147 -0
  57. package/tests/hub_account_page.mjs +2 -2
  58. package/tests/hub_community_page.mjs +213 -29
  59. package/tests/landing_legal_pages.mjs +2 -2
  60. package/tests/privacy_matrix.mjs +107 -9
@@ -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
+ }
@@ -135,8 +135,8 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
135
135
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
136
136
  ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
137
137
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
138
- // the Community menu's four sub-pages, folded into the band under 480px (tasks 1003363, 1002981)
139
- ['Leaderboard', '/community'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
138
+ // the Community menu's five sub-pages, folded into the band under 480px (tasks 1003363, 1002981, 1004902)
139
+ ['Community home', '/community'], ['Leaderboard', '/community/leaderboard'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
140
140
  ['Builders you may know', '/connections#connSuggest'],
141
141
  ], 'this page has no views of its own, so every item names /projects in full');
142
142
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [