@bongos/core 1.19.1024 → 1.19.1026
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 +80 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
- package/docs/api/openapi.json +106 -5
- package/docs/api-reference.md +7 -5
- package/docs/copy-inventory.md +19 -17
- package/docs/copy-registry.json +44 -36
- package/docs/module-api-changelog.md +5 -1
- package/migrations/core_255_access_requests_invite_kind.sql +52 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/onboarding/routes/access-requests.js +62 -11
- package/modules/platform-identity/community-leaderboard.js +235 -0
- package/modules/platform-identity/connection-suggestions.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +61 -0
- package/modules/platform-identity/routes/community-leaderboard.js +69 -0
- package/modules/platform-identity/routes/community-search.js +4 -31
- package/modules/platform-identity/routes/projects.js +1 -1
- package/modules/platform-identity/terms-acceptance.js +34 -14
- package/modules/public-landing/public/terms.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +10 -0
- package/src/module-api.js +1 -1
- package/tests/access_requests_invite.mjs +81 -4
- package/tests/application_lifecycle.mjs +174 -6
- package/tests/community_leaderboard_db.mjs +202 -0
- package/tests/community_leaderboard_route.mjs +414 -0
- package/tests/connection_suggestions.mjs +1 -1
- package/tests/connections_api.mjs +1 -1
- package/tests/goal_map_page.mjs +8 -0
- package/tests/handle_machinery.mjs +2 -0
- package/tests/profile_connections_ui.mjs +2 -2
- package/tests/profile_rollup_consent.mjs +3 -0
- package/tests/terms_acceptance.mjs +264 -14
- package/tests/terms_grandfather_db.mjs +144 -0
|
@@ -65,11 +65,14 @@ let nextId = 100;
|
|
|
65
65
|
// comparing them rather than by reading the code and believing it.
|
|
66
66
|
function seed() {
|
|
67
67
|
const now = Date.now();
|
|
68
|
+
// `kind`/`created_by` carry core_255's column default on every seeded row:
|
|
69
|
+
// each of these is an application, and cy's is an APPROVED one.
|
|
70
|
+
const app = { kind: 'application', created_by: null };
|
|
68
71
|
rows = [
|
|
69
|
-
{ id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
|
|
70
|
-
{ id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
|
|
71
|
-
{ id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR },
|
|
72
|
-
{ id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR },
|
|
72
|
+
{ id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
|
|
73
|
+
{ id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
|
|
74
|
+
{ id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR, ...app },
|
|
75
|
+
{ id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR, ...app },
|
|
73
76
|
];
|
|
74
77
|
builders = [{ github_login: 'existing' }];
|
|
75
78
|
statements = [];
|
|
@@ -102,6 +105,15 @@ function predicateFor(rawWhere, params) {
|
|
|
102
105
|
where = where.replace(transition[0], ' TRUE ');
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
// The outstanding-invite list's member exclusion (task 1002814): an invitee who
|
|
109
|
+
// has signed in is a member, not an outstanding invite. Stripped as one unit,
|
|
110
|
+
// like the transition guard, so its inner `lower(...)` is not re-read.
|
|
111
|
+
const notMember = /NOT EXISTS \(SELECT 1 FROM builders b WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\)/.exec(where);
|
|
112
|
+
if (notMember) {
|
|
113
|
+
tests.push((r) => !isBuilder(r.github_login));
|
|
114
|
+
where = where.replace(notMember[0], ' TRUE ');
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
let m;
|
|
106
118
|
const eat = () => { where = where.replace(m[0], ' TRUE '); };
|
|
107
119
|
if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
|
|
@@ -112,6 +124,8 @@ function predicateFor(rawWhere, params) {
|
|
|
112
124
|
}
|
|
113
125
|
if ((m = /\b(?:ar\.)?status = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => r.status === v); eat(); }
|
|
114
126
|
if ((m = /\b(?:ar\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
|
|
127
|
+
if ((m = /\b(?:ar\.)?kind = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.kind === v); eat(); }
|
|
128
|
+
if ((m = /\b(?:ar\.)?created_by = \$(\d+)/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.created_by) === String(v)); eat(); }
|
|
115
129
|
if ((m = /\bapplicant_github_id IS NULL\b/.exec(where))) { tests.push((r) => r.applicant_github_id == null); eat(); }
|
|
116
130
|
|
|
117
131
|
for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
|
|
@@ -181,11 +195,47 @@ const pool = {
|
|
|
181
195
|
applicant_github_id: applicantGithubId, applicant_handle: applicantHandle, vouched_at: vouchedAt,
|
|
182
196
|
resolved_by_builder_id: null, resolved_at: null,
|
|
183
197
|
created_at: Date.now(),
|
|
198
|
+
kind: 'application', created_by: null, // the column defaults — the public write names neither
|
|
184
199
|
};
|
|
185
200
|
rows.push(fresh);
|
|
186
201
|
return { rows: [{ id: fresh.id, github_login: fresh.github_login, status: fresh.status, created_at: fresh.created_at }] };
|
|
187
202
|
}
|
|
188
203
|
|
|
204
|
+
// The invite route's fresh row (task 1002814, ADR 0335 D2). Matched EXACTLY,
|
|
205
|
+
// like the public write above, so `kind` or `created_by` cannot be dropped
|
|
206
|
+
// from the real statement with every test still green.
|
|
207
|
+
if (/^INSERT INTO access_requests \(github_login, note, kind, created_by, status, resolved_by_builder_id, resolved_at\) VALUES \(\$1, \$2, 'invite', \$3, 'invited', \$3, now\(\)\) RETURNING id, github_login, status, kind, resolved_at$/.test(sql)) {
|
|
208
|
+
const [login, note, sender] = params;
|
|
209
|
+
const at = Date.now();
|
|
210
|
+
const fresh = {
|
|
211
|
+
id: nextId++, github_login: login, display_name: null, note, status: 'invited',
|
|
212
|
+
applicant_github_id: null, applicant_handle: null, vouched_at: null,
|
|
213
|
+
resolved_by_builder_id: sender, resolved_at: at, created_at: at,
|
|
214
|
+
kind: 'invite', created_by: sender,
|
|
215
|
+
};
|
|
216
|
+
rows.push(fresh);
|
|
217
|
+
return { rows: [{ id: fresh.id, github_login: login, status: 'invited', kind: 'invite', resolved_at: at }] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The invite route resolving a PENDING application in place. The SET list is
|
|
221
|
+
// matched exactly: it must never touch kind, created_by or the applicant's note.
|
|
222
|
+
if ((m = /^UPDATE access_requests SET status = 'invited', resolved_by_builder_id = \$1, resolved_at = now\(\) WHERE (.+?) RETURNING id, github_login, status, kind, resolved_at$/.exec(sql))) {
|
|
223
|
+
const hit = rows.filter(predicateFor(m[1], params));
|
|
224
|
+
for (const r of hit) { r.status = 'invited'; r.resolved_by_builder_id = params[0]; r.resolved_at = Date.now(); }
|
|
225
|
+
return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, kind: r.kind, resolved_at: r.resolved_at })) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The outstanding-invite list. Its sender is joined through created_by.
|
|
229
|
+
if ((m = /^SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?) ORDER BY ar\.created_at DESC LIMIT 200$/.exec(sql))) {
|
|
230
|
+
const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
|
|
231
|
+
return {
|
|
232
|
+
rows: hit.map((r) => ({
|
|
233
|
+
id: r.id, github_login: r.github_login, note: r.note, created_at: r.created_at,
|
|
234
|
+
invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null,
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
189
239
|
// The owner's queue.
|
|
190
240
|
if ((m = /^SELECT ar\.id, .* FROM access_requests ar .* WHERE (.+?) ORDER BY/.exec(sql))) {
|
|
191
241
|
const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
|
|
@@ -197,6 +247,9 @@ const pool = {
|
|
|
197
247
|
// Returned only when the real SELECT names it, so the queue test below
|
|
198
248
|
// proves the route reads the column rather than that this fake invents it.
|
|
199
249
|
...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
|
|
250
|
+
...(/\bar\.kind\b/.test(sql) ? { kind: r.kind } : {}),
|
|
251
|
+
...(/\bib\.github_login AS invited_by_login\b/.test(sql) && /\bON ib\.id = ar\.created_by\b/.test(sql)
|
|
252
|
+
? { invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null } : {}),
|
|
200
253
|
vouched: r.applicant_github_id !== null,
|
|
201
254
|
resolved_by_login: null,
|
|
202
255
|
})),
|
|
@@ -280,6 +333,9 @@ const dbKernel = require('../src/bongos/db-kernel.js');
|
|
|
280
333
|
// Who is calling, and at what LIVE rank. The rank is resolved through the real
|
|
281
334
|
// government port on every request — never cached — exactly as production does.
|
|
282
335
|
let actingRank = 'archon';
|
|
336
|
+
// Which builder is acting — switched only to prove an invite keeps its SENDER
|
|
337
|
+
// when a different archon rescinds it.
|
|
338
|
+
let actingId = 42;
|
|
283
339
|
|
|
284
340
|
require.cache[apiPath].exports = {
|
|
285
341
|
...realApi,
|
|
@@ -287,7 +343,7 @@ require.cache[apiPath].exports = {
|
|
|
287
343
|
joinabilityMode: () => 'apply',
|
|
288
344
|
projectJoinDoor: () => 'apply',
|
|
289
345
|
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
290
|
-
requireBuilder: (req, _res, next) => { req.builder = { id:
|
|
346
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: actingId, github_login: 'owner' }; next(); },
|
|
291
347
|
// requirePermission is NOT stubbed: realApi's own gate is used, so the denial
|
|
292
348
|
// below is the production denial.
|
|
293
349
|
readApplicationVouch: (token, { githubLogin } = {}) =>
|
|
@@ -336,7 +392,7 @@ function call(method, pathname, body, extraHeaders = {}) {
|
|
|
336
392
|
req.end(payload);
|
|
337
393
|
});
|
|
338
394
|
}
|
|
339
|
-
const reset = (rank = 'archon') => { seed(); actingRank = rank; };
|
|
395
|
+
const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; };
|
|
340
396
|
|
|
341
397
|
// ===========================================================================
|
|
342
398
|
// The interpreter is the instrument, so it is calibrated first
|
|
@@ -692,6 +748,118 @@ test('only invited|dismissed are accepted, and a rejected status writes nothing'
|
|
|
692
748
|
assert.equal(JSON.stringify(rows), snapshot);
|
|
693
749
|
});
|
|
694
750
|
|
|
751
|
+
// ===========================================================================
|
|
752
|
+
// Invite — a pre-approved row the gate already reads (task 1002814, ADR 0335 D2)
|
|
753
|
+
// ===========================================================================
|
|
754
|
+
|
|
755
|
+
// The two archons the invite tests act as. Only their ids matter: the sender is
|
|
756
|
+
// joined through created_by → builders.id.
|
|
757
|
+
const ARCHONS = [{ id: 42, github_login: 'owner' }, { id: 43, github_login: 'second' }];
|
|
758
|
+
|
|
759
|
+
test('invite: a login that never applied signs straight in, and the row says it is an invite and who sent it', async () => {
|
|
760
|
+
reset();
|
|
761
|
+
builders.push(...ARCHONS);
|
|
762
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'precondition: eve never applied');
|
|
763
|
+
const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
|
|
764
|
+
assert.equal(r.status, 201);
|
|
765
|
+
assert.equal(r.json.request.kind, 'invite');
|
|
766
|
+
const eve = rows.find((x) => x.github_login === 'eve');
|
|
767
|
+
assert.equal(eve.kind, 'invite');
|
|
768
|
+
assert.equal(eve.created_by, 42, 'the sender');
|
|
769
|
+
assert.equal(eve.note, 'Come build the parser', 'the inviter’s words, and kind says whose');
|
|
770
|
+
statements = [];
|
|
771
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'the REAL admission read admits her — signing in is the acceptance act');
|
|
772
|
+
assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bkind\b|\bcreated_by\b/.test(sql)),
|
|
773
|
+
'and it admitted her without reading either new column (D2.5)');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('rescind: an invite keeps its sender through a rescind by a different archon, and the door re-locks', async () => {
|
|
777
|
+
reset();
|
|
778
|
+
builders.push(...ARCHONS);
|
|
779
|
+
const sent = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
|
|
780
|
+
actingId = 43;
|
|
781
|
+
const r = await call('PATCH', `/api/gds/access-requests/${sent.json.request.id}`, { status: 'dismissed' });
|
|
782
|
+
assert.equal(r.status, 200);
|
|
783
|
+
const eve = rows.find((x) => x.github_login === 'eve');
|
|
784
|
+
assert.equal(eve.status, 'dismissed');
|
|
785
|
+
assert.equal(eve.resolved_by_builder_id, 43, 'resolved_by records whoever acted last (ADR 0201 D4.2)…');
|
|
786
|
+
assert.equal(eve.created_by, 42, '…so the sender needs its own column, and the rescind does not touch it (D2.2)');
|
|
787
|
+
assert.equal(eve.kind, 'invite', 'a rescinded invite is still an invite in the history');
|
|
788
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'the door is shut again');
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test('invites list: outstanding only — not an approved application, a rescinded invite, or an invitee who signed in', async () => {
|
|
792
|
+
reset();
|
|
793
|
+
builders.push(...ARCHONS);
|
|
794
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'hi' });
|
|
795
|
+
actingId = 43;
|
|
796
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
797
|
+
const gus = await call('POST', '/api/gds/access-requests/invite', { github_login: 'gus' });
|
|
798
|
+
await call('PATCH', `/api/gds/access-requests/${gus.json.request.id}`, { status: 'dismissed' });
|
|
799
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'hal' });
|
|
800
|
+
builders.push({ id: 90, github_login: 'HAL' }); // hal accepted: he signed in
|
|
801
|
+
const r = await call('GET', '/api/gds/access-requests/invites');
|
|
802
|
+
assert.equal(r.status, 200);
|
|
803
|
+
const byLogin = Object.fromEntries(r.json.invites.map((x) => [x.github_login, x]));
|
|
804
|
+
assert.deepEqual(Object.keys(byLogin).sort(), ['eve', 'fay'],
|
|
805
|
+
'cy (an approved application), gus (rescinded) and hal (signed in, case-insensitively) are not outstanding');
|
|
806
|
+
assert.equal(byLogin.eve.invited_by_login, 'owner');
|
|
807
|
+
assert.equal(byLogin.fay.invited_by_login, 'second', 'each names its own sender');
|
|
808
|
+
assert.equal(byLogin.eve.note, 'hi');
|
|
809
|
+
|
|
810
|
+
const mine43 = await call('GET', '/api/gds/access-requests/invites?mine=1');
|
|
811
|
+
assert.deepEqual(mine43.json.invites.map((x) => x.github_login), ['fay'], '?mine=1: the sender lists their own outstanding invites');
|
|
812
|
+
actingId = 42;
|
|
813
|
+
const mine42 = await call('GET', '/api/gds/access-requests/invites?mine=1');
|
|
814
|
+
assert.deepEqual(mine42.json.invites.map((x) => x.github_login), ['eve']);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('the queue history tells an invite from an approved application, and names the sender', async () => {
|
|
818
|
+
reset();
|
|
819
|
+
builders.push(...ARCHONS);
|
|
820
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
|
|
821
|
+
await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' }); // approve ada's application
|
|
822
|
+
const r = await call('GET', '/api/gds/access-requests?status=invited');
|
|
823
|
+
const byLogin = Object.fromEntries(r.json.requests.map((x) => [x.github_login, x]));
|
|
824
|
+
assert.equal(byLogin.cy.kind, 'application', 'cy applied and was approved');
|
|
825
|
+
assert.equal(byLogin.cy.invited_by_login, null);
|
|
826
|
+
assert.equal(byLogin.eve.kind, 'invite', 'eve never applied — an archon invited her');
|
|
827
|
+
assert.equal(byLogin.eve.invited_by_login, 'owner');
|
|
828
|
+
// `note` on this read has always meant the APPLICANT's words (the hall quotes it
|
|
829
|
+
// as theirs), so an inviter's note must never arrive in it.
|
|
830
|
+
assert.equal(byLogin.eve.note, null, 'an inviter’s note is not the invitee’s words');
|
|
831
|
+
assert.equal(byLogin.eve.invite_note, 'Come build the parser');
|
|
832
|
+
assert.equal(byLogin.ada.note, 'I build tools', 'an applicant’s note stays where it always was');
|
|
833
|
+
assert.equal(byLogin.ada.invite_note, null);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test('invite: resolving a pending application in place keeps it an application, with the applicant’s own note', async () => {
|
|
837
|
+
reset();
|
|
838
|
+
builders.push(...ARCHONS);
|
|
839
|
+
const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'ada', note: 'not their words' });
|
|
840
|
+
assert.equal(r.status, 200);
|
|
841
|
+
assert.equal(r.json.request.kind, 'application');
|
|
842
|
+
assert.equal(row(1).status, 'invited');
|
|
843
|
+
assert.equal(row(1).kind, 'application', 'it was an application, and its history says so (D2.1)');
|
|
844
|
+
assert.equal(row(1).created_by, null, 'created_by is set on invite rows only (D2.2)');
|
|
845
|
+
assert.equal(row(1).note, 'I build tools', 'an inviter never overwrites the applicant’s note');
|
|
846
|
+
assert.equal(rows.filter((x) => x.github_login === 'ada').length, 1, 'resolved, not duplicated');
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
for (const rank of ['thetes', 'metic']) {
|
|
850
|
+
test(`a ${rank} can neither invite nor list outstanding invites — access_request.review until R3 mints project.recruit`, async () => {
|
|
851
|
+
reset(rank);
|
|
852
|
+
const snapshot = JSON.stringify(rows);
|
|
853
|
+
const invited = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
|
|
854
|
+
assert.equal(invited.status, 403);
|
|
855
|
+
assert.deepEqual(invited.json.error.details.required, ['access_request.review']);
|
|
856
|
+
const listed = await call('GET', '/api/gds/access-requests/invites');
|
|
857
|
+
assert.equal(listed.status, 403, 'the list names logins that are not members yet');
|
|
858
|
+
assert.equal(JSON.stringify(rows), snapshot, 'nothing was written');
|
|
859
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
695
863
|
// ===========================================================================
|
|
696
864
|
// Authority — "a builder cannot approve or decline for a project they don't own"
|
|
697
865
|
// ===========================================================================
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// tests/community_leaderboard_db.mjs — task 1002978 (community spec D2/D3; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves the windowed leaderboard's arithmetic and eligibility against real
|
|
4
|
+
// SQL. The window math is a LATERAL "last snapshot at-or-before the window
|
|
5
|
+
// start" probe per (project, builder) plus a cross-project sum, and a fake pool
|
|
6
|
+
// can only prove the statement's SHAPE (tests/community_leaderboard_route.mjs);
|
|
7
|
+
// only a real Postgres can prove which snapshot becomes the baseline, that a
|
|
8
|
+
// missing baseline counts as zero, and that the privacy matrix really filters.
|
|
9
|
+
//
|
|
10
|
+
// Every fixture carries a discipline no real builder holds, and every read
|
|
11
|
+
// filters on it, so a shared database's own builders never enter the board
|
|
12
|
+
// under test.
|
|
13
|
+
//
|
|
14
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
15
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the DB-free lane never runs it.
|
|
16
|
+
//
|
|
17
|
+
// Run: DATABASE_URL=... node tests/community_leaderboard_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 { listLeaderboard, clearTotalsMemo } = require('../modules/platform-identity/community-leaderboard.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_leaderboard_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
37
|
+
await pool.end().catch(() => {});
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
42
|
+
const A = 90297801, B = 90297802, C = 90297803, D = 90297804, OPTOUT = 90297805;
|
|
43
|
+
const HIDDEN = 90297806, PRIVATE = 90297807, PROV = 90297808, LEGACY = 90297809, BIG = 90297810;
|
|
44
|
+
const IDS = [A, B, C, D, OPTOUT, HIDDEN, PRIVATE, PROV, LEGACY, BIG];
|
|
45
|
+
const P1 = 't1002978-p1', P2 = 't1002978-p2';
|
|
46
|
+
const TAG = 't1002978-board'; // every fixture holds it; every read filters on it
|
|
47
|
+
const TAG_ART = 't1002978-art'; // held by A on P2 only — the union filter
|
|
48
|
+
const TAG_BIG = 't1002978-big'; // BIG's own board, so its totals stay out of the others
|
|
49
|
+
const INT4_MAX = 2147483647;
|
|
50
|
+
const NOW = new Date('2026-06-01T12:00:00.000Z');
|
|
51
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
52
|
+
const ago = (days, extraMs = 0) => new Date(NOW.getTime() - days * DAY + extraMs);
|
|
53
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
54
|
+
|
|
55
|
+
async function cleanup() {
|
|
56
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
57
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
58
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function account(id, handle, { state = 'active', visibility = 'public', hide = false, accepted = true, reach = null } = {}) {
|
|
62
|
+
await q(
|
|
63
|
+
`INSERT INTO platform_identity_accounts
|
|
64
|
+
(github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, recruiter_discoverable, terms_accepted_at)
|
|
65
|
+
VALUES ($1, $2, $2, $2, $3, $4, $5, $6, CASE WHEN $7::boolean THEN now() END)`,
|
|
66
|
+
[id, handle, state, visibility, hide, reach, accepted],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
async function activity(id, client, credits, ships, karma, disciplines = [TAG]) {
|
|
70
|
+
await q(
|
|
71
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, disciplines)
|
|
72
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
73
|
+
[client, id, credits, ships, karma, disciplines],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
async function snapshot(id, client, credits, ships, karma, at) {
|
|
77
|
+
await q(
|
|
78
|
+
`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
|
|
79
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
80
|
+
[client, id, credits, ships, karma, at],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const board = async (window, discipline = TAG) =>
|
|
85
|
+
(await listLeaderboard({ window, discipline, limit: 100, now: NOW, pool })).results;
|
|
86
|
+
const row = (results, handle) => results.find((r) => r.handle === handle);
|
|
87
|
+
const totals = (r) => r && [r.credits, r.tasks_shipped, r.karma];
|
|
88
|
+
|
|
89
|
+
let pass = 0;
|
|
90
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
91
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// The module's own migrations (idempotent) — every column the NUMBERS
|
|
95
|
+
// predicate and the board read: 007 consent + hide, 008 handle, 012 privacy,
|
|
96
|
+
// 019 the terms half of consent, 005/022 the two tables the window reads.
|
|
97
|
+
for (const f of [
|
|
98
|
+
'platform_identity_001_tables.sql', 'platform_identity_005_builder_activity.sql',
|
|
99
|
+
'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
|
|
100
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
101
|
+
'platform_identity_022_activity_snapshots.sql',
|
|
102
|
+
]) await q(migrationSql(f));
|
|
103
|
+
await cleanup();
|
|
104
|
+
|
|
105
|
+
await account(A, 't1002978-a');
|
|
106
|
+
await account(B, 't1002978-b');
|
|
107
|
+
await account(C, 't1002978-c');
|
|
108
|
+
await account(D, 't1002978-d');
|
|
109
|
+
await account(OPTOUT, 't1002978-optout', { reach: false });
|
|
110
|
+
await account(HIDDEN, 't1002978-hidden', { hide: true });
|
|
111
|
+
await account(PRIVATE, 't1002978-private', { visibility: 'private' });
|
|
112
|
+
await account(PROV, 't1002978-prov', { state: 'provisional', accepted: false });
|
|
113
|
+
await account(LEGACY, 't1002978-legacy', { accepted: false }); // active, but never accepted the terms
|
|
114
|
+
|
|
115
|
+
// A: two projects. P1 has history; P2 has none (a builder not seen since
|
|
116
|
+
// snapshots began — the day-one case the spec accepts).
|
|
117
|
+
await activity(A, P1, 200, 9, 3);
|
|
118
|
+
await snapshot(A, P1, 100, 5, 2, ago(40));
|
|
119
|
+
await snapshot(A, P1, 150, 7, 3, ago(10));
|
|
120
|
+
await snapshot(A, P1, 200, 9, 3, ago(2));
|
|
121
|
+
await activity(A, P2, 30, 1, 0, [TAG, TAG_ART]);
|
|
122
|
+
// B: unchanged for the last 20 days.
|
|
123
|
+
await activity(B, P1, 500, 10, 1);
|
|
124
|
+
await snapshot(B, P1, 500, 10, 1, ago(20));
|
|
125
|
+
// C: a snapshot EXACTLY at the 7d start, and one a second after it.
|
|
126
|
+
await activity(C, P1, 45, 2, 0);
|
|
127
|
+
await snapshot(C, P1, 40, 2, 0, ago(7));
|
|
128
|
+
await snapshot(C, P1, 42, 2, 0, ago(7, 1000));
|
|
129
|
+
// D: lost credits inside the week (a clawback).
|
|
130
|
+
await activity(D, P1, 90, 3, 5);
|
|
131
|
+
await snapshot(D, P1, 100, 3, 5, ago(10));
|
|
132
|
+
await activity(OPTOUT, P1, 60, 2, 0);
|
|
133
|
+
// Out of the matrix — every one would top the board if it leaked.
|
|
134
|
+
for (const id of [HIDDEN, PRIVATE, PROV, LEGACY]) await activity(id, P1, 9000, 90, 9);
|
|
135
|
+
await account(BIG, 't1002978-big');
|
|
136
|
+
await activity(BIG, P1, INT4_MAX, 1, 0, [TAG_BIG]);
|
|
137
|
+
await activity(BIG, P2, INT4_MAX, 1, 0, [TAG_BIG]);
|
|
138
|
+
await snapshot(BIG, P1, -INT4_MAX, 0, 0, ago(10));
|
|
139
|
+
|
|
140
|
+
// 1 — all-time: the live rows summed across projects, no baseline.
|
|
141
|
+
const all = await board('all');
|
|
142
|
+
same(all.map((r) => r.handle), ['t1002978-b', 't1002978-a', 't1002978-d', 't1002978-optout', 't1002978-c'],
|
|
143
|
+
'all-time ranks by summed credits');
|
|
144
|
+
same(totals(row(all, 't1002978-a')), [230, 10, 3], 'all-time sums a builder across both projects');
|
|
145
|
+
same(all.map((r) => r.place), [1, 2, 3, 4, 5], 'place numbers the board from 1');
|
|
146
|
+
|
|
147
|
+
// 2 — the privacy matrix, against the real predicate.
|
|
148
|
+
for (const window of ['all', '7d', '30d', '365d']) {
|
|
149
|
+
const handles = (await board(window)).map((r) => r.handle);
|
|
150
|
+
for (const h of ['t1002978-hidden', 't1002978-private', 't1002978-prov', 't1002978-legacy']) {
|
|
151
|
+
assert.ok(!handles.includes(h), `${h} leaked onto the ${window} board`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
pass++; console.log(' ok - hidden, private, provisional and never-accepted builders are absent from every window');
|
|
155
|
+
ok(row(all, 't1002978-optout'), 'a builder who opted out of RECRUITING still appears (spec D3)');
|
|
156
|
+
|
|
157
|
+
// 3 — 7d: live minus the last snapshot at-or-before the start, summed.
|
|
158
|
+
const week = await board('7d');
|
|
159
|
+
same(totals(row(week, 't1002978-a')), [80, 3, 0],
|
|
160
|
+
'7d: P1 counts from the 10-day-old snapshot, P2 (no history) counts in full');
|
|
161
|
+
same(totals(row(week, 't1002978-c')), [5, 0, 0],
|
|
162
|
+
'7d: a snapshot exactly at the window start IS the baseline; one a second later is not');
|
|
163
|
+
same(totals(row(week, 't1002978-d')), [-10, 0, 0], '7d: a loss inside the window is a negative delta, not hidden');
|
|
164
|
+
ok(!row(week, 't1002978-b'), '7d: a builder with no movement in the window is left off the board');
|
|
165
|
+
same(week.map((r) => r.handle), ['t1002978-a', 't1002978-optout', 't1002978-c', 't1002978-d'],
|
|
166
|
+
'7d ranks by the window delta, a loss last');
|
|
167
|
+
|
|
168
|
+
// 4 — 30d and 365d.
|
|
169
|
+
const month = await board('30d');
|
|
170
|
+
same(totals(row(month, 't1002978-a')), [130, 5, 1], '30d: P1 counts from the 40-day-old snapshot');
|
|
171
|
+
same(totals(row(month, 't1002978-b')), [500, 10, 1],
|
|
172
|
+
'30d: a builder whose first snapshot is inside the window has no baseline, so counts in full');
|
|
173
|
+
const year = await board('365d');
|
|
174
|
+
same(totals(row(year, 't1002978-a')), [230, 10, 3], '365d: no snapshot is old enough, so everything counts');
|
|
175
|
+
|
|
176
|
+
// 5 — the discipline filter is the builder's union across projects.
|
|
177
|
+
const art = await board('all', TAG_ART);
|
|
178
|
+
same(art.map((r) => r.handle), ['t1002978-a'], 'a discipline held on ONE project admits the builder');
|
|
179
|
+
same(totals(art[0]), [230, 10, 3], 'the filter picks builders, not projects — totals stay whole');
|
|
180
|
+
same(art[0].disciplines, [TAG_ART, TAG].sort(), 'disciplines is the de-duplicated union');
|
|
181
|
+
|
|
182
|
+
// 6 — instance-reported int4 totals cannot overflow the board into a 500.
|
|
183
|
+
same(totals((await board('all', TAG_BIG))[0]), [2 * INT4_MAX, 2, 0], 'all-time: two int4-max projects sum past int4');
|
|
184
|
+
same(totals((await board('7d', TAG_BIG))[0]), [3 * INT4_MAX, 2, 0], '7d: int4-max minus a negative baseline does not overflow');
|
|
185
|
+
|
|
186
|
+
// 7 — paging keeps the board's own place numbers.
|
|
187
|
+
const page2 = (await listLeaderboard({ window: 'all', discipline: TAG, limit: 2, offset: 2, now: NOW, pool })).results;
|
|
188
|
+
same(page2.map((r) => [r.place, r.handle]), [[3, 't1002978-d'], [4, 't1002978-optout']], 'offset pages keep place');
|
|
189
|
+
|
|
190
|
+
// 8 — the memoized path (no `now`) against real SQL: hiding beats the memo.
|
|
191
|
+
clearTotalsMemo();
|
|
192
|
+
const memo = async () => (await listLeaderboard({ window: 'all', discipline: TAG, limit: 100, pool })).results;
|
|
193
|
+
ok(row(await memo(), 't1002978-a'), 'the memoized all-time board lists A');
|
|
194
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [A]);
|
|
195
|
+
ok(!row(await memo(), 't1002978-a'), 'A hides inside the memo TTL and is gone on the very next request');
|
|
196
|
+
clearTotalsMemo();
|
|
197
|
+
|
|
198
|
+
console.log(`\ncommunity_leaderboard_db.mjs: ${pass} passed`);
|
|
199
|
+
} finally {
|
|
200
|
+
await cleanup();
|
|
201
|
+
await pool.end().catch(() => {});
|
|
202
|
+
}
|