@bongos/core 1.19.561 → 1.19.563
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 +63 -33
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +69 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +127 -127
- package/docs/copy-registry.json +162 -162
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/lifecycle/db-versions.js +22 -0
- package/modules/lifecycle/db.js +2 -1
- package/modules/lifecycle/routes/goal-route-authz.js +58 -0
- package/modules/lifecycle/routes/goals.js +8 -3
- package/modules/lifecycle/routes/version-route-authz.js +56 -0
- package/modules/lifecycle/routes/versions.js +22 -0
- package/modules/platform-identity/invite-suggestions.js +57 -0
- package/modules/platform-identity/routes/sso.js +8 -0
- package/modules/provisioning/routes/provisioning.js +56 -0
- package/modules/public-landing/public/assets/cosmos.css +16 -0
- package/modules/public-landing/public/projects.html +85 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_create_version_gate.mjs +136 -0
- package/tests/one_planning_version.mjs +161 -0
- package/tests/project_invite_suggestions.mjs +133 -0
- package/tests/project_invite_ui.mjs +171 -18
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// tests/goal_create_version_gate.mjs — a version's goal set closes when the
|
|
2
|
+
// version starts building (BV1.R03, task 1003591, goal 1000086, ADR 0250 D1).
|
|
3
|
+
//
|
|
4
|
+
// THE HOLE. `POST /goals` fetched the version row and discarded it after the null
|
|
5
|
+
// check. `version.status` was never read, so a goal could be added to a version
|
|
6
|
+
// that had been building for months — which is how a version's scope grows
|
|
7
|
+
// without anyone deciding to widen it, and why 64 goals accumulated on
|
|
8
|
+
// BONGOS-V1. New goals belong on the `planning` version: that is what a planning
|
|
9
|
+
// version is FOR, scoping the next one in advance.
|
|
10
|
+
//
|
|
11
|
+
// The gate is pure and DB-free (the `authorizeGoalTaskCreate` precedent), so its
|
|
12
|
+
// whole truth table is exercised here without a database, and the route's only
|
|
13
|
+
// job is to render the decision.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/goal_create_version_gate.mjs
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
process.env.NODE_ENV = 'test';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { authorizeGoalCreate } = require('../modules/lifecycle/routes/goal-route-authz.js');
|
|
25
|
+
const { versionAcceptsNewWork } = require('../modules/lifecycle/db-tasks.js');
|
|
26
|
+
|
|
27
|
+
const V = (status) => ({ id: 'BONGOS-V1', status });
|
|
28
|
+
const decide = (version, override) => authorizeGoalCreate({ version, override });
|
|
29
|
+
|
|
30
|
+
// ---- the truth table --------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
test('planning accepts — that is what a planning version is for', () => {
|
|
33
|
+
assert.deepEqual(decide(V('planning')), { ok: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('building refuses with a named 409 an agent can act on', () => {
|
|
37
|
+
const d = decide(V('building'));
|
|
38
|
+
assert.equal(d.ok, false);
|
|
39
|
+
assert.equal(d.status, 409);
|
|
40
|
+
assert.equal(d.body.error, 'version_not_planning');
|
|
41
|
+
assert.equal(d.body.status, 'building', 'the caller learns WHICH state refused');
|
|
42
|
+
assert.match(d.body.message, /planning version/,
|
|
43
|
+
'and where the goal should go instead — a refusal with no next move gets routed around');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('shipped refuses as closed history, not as "not planning"', () => {
|
|
47
|
+
// Two different facts deserve two different codes: a building version might
|
|
48
|
+
// still admit this goal via an Archon override; a shipped one never will.
|
|
49
|
+
const d = decide(V('shipped'));
|
|
50
|
+
assert.equal(d.body.error, 'version_closed');
|
|
51
|
+
assert.match(d.body.message, /has shipped/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a missing version is a 404, before any status reasoning', () => {
|
|
55
|
+
const d = decide(null);
|
|
56
|
+
assert.equal(d.status, 404);
|
|
57
|
+
assert.equal(d.body.error, 'version_not_found');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('FAIL-CLOSED: a status nobody taught this gate about refuses', () => {
|
|
61
|
+
// A version status added later must not default to "sure, file it here".
|
|
62
|
+
for (const status of ['paused', 'draft', '', undefined, null, 42]) {
|
|
63
|
+
const d = decide(V(status));
|
|
64
|
+
assert.equal(d.ok, false, `'${String(status)}' must not be accepted`);
|
|
65
|
+
assert.equal(d.body.error, 'version_closed');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an unrecognised status is NOT described as shipped', () => {
|
|
70
|
+
// The fail-closed arm catches more than `shipped`, and telling someone their
|
|
71
|
+
// version "has shipped" when it says 'paused' is a confident lie about state
|
|
72
|
+
// they can see for themselves.
|
|
73
|
+
const d = decide(V('paused'));
|
|
74
|
+
assert.match(d.body.message, /is 'paused'/);
|
|
75
|
+
assert.equal(/has shipped/.test(d.body.message), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- the override seam (R07 is not built yet) -------------------------------
|
|
79
|
+
|
|
80
|
+
test('override admits a building version — the R07 seam, already shaped', () => {
|
|
81
|
+
assert.deepEqual(decide(V('building'), true), { ok: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('override does NOT reopen a shipped version', () => {
|
|
85
|
+
// The escape hatch is for a scope decision, not for rewriting closed history.
|
|
86
|
+
assert.equal(decide(V('shipped'), true).body.error, 'version_closed');
|
|
87
|
+
assert.equal(decide(V('paused'), true).body.error, 'version_closed');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('only a literal true overrides — a truthy value does not', () => {
|
|
91
|
+
for (const v of ['yes', 1, {}, [] ]) {
|
|
92
|
+
assert.equal(decide(V('building'), v).ok, false,
|
|
93
|
+
'a JSON body coercing to truthy must not open the gate');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- one definition of "open to work" ---------------------------------------
|
|
98
|
+
|
|
99
|
+
test('the gate reuses versionAcceptsNewWork rather than respelling the statuses', () => {
|
|
100
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goal-route-authz.js', import.meta.url), 'utf8');
|
|
101
|
+
const body = src.slice(src.indexOf('function authorizeGoalCreate'), src.indexOf('function authorizeGoalTaskCreate'));
|
|
102
|
+
assert.match(body, /versionAcceptsNewWork\(version\.status\)/);
|
|
103
|
+
assert.equal(/'shipped'\s*,\s*'planning'|new Set\(\[/.test(body), false,
|
|
104
|
+
'no second copy of the status list lives in this gate');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the gate is STRICTER than versionAcceptsNewWork, deliberately', () => {
|
|
108
|
+
// Work may still LAND on a building version; its SCOPE may not grow. Those are
|
|
109
|
+
// two different questions and this pins that they give different answers.
|
|
110
|
+
assert.equal(versionAcceptsNewWork('building'), true, 'tasks may still be filed');
|
|
111
|
+
assert.equal(decide(V('building')).ok, false, 'but a new goal may not');
|
|
112
|
+
assert.equal(versionAcceptsNewWork('planning'), true);
|
|
113
|
+
assert.equal(decide(V('planning')).ok, true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---- the route renders the decision and nothing else ------------------------
|
|
117
|
+
|
|
118
|
+
test('POST /goals consults the gate and returns its exact status + body', () => {
|
|
119
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
120
|
+
const start = src.indexOf("router.post('/goals'");
|
|
121
|
+
assert.notEqual(start, -1);
|
|
122
|
+
const body = src.slice(start, src.indexOf('router.', start + 20));
|
|
123
|
+
assert.match(body, /const versionDecision = authorizeGoalCreate\(\{ version \}\);/);
|
|
124
|
+
assert.match(body, /return res\.status\(versionDecision\.status\)\.json\(versionDecision\.body\);/,
|
|
125
|
+
'the route renders the decision, it does not re-derive one');
|
|
126
|
+
assert.equal(/version\.status\s*[=!]==/.test(body), false,
|
|
127
|
+
'and it must not compare the status itself — that is the gate\'s job');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the 404 moved INTO the gate, so the route has no second version check', () => {
|
|
131
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
132
|
+
const start = src.indexOf("router.post('/goals'");
|
|
133
|
+
const body = src.slice(start, src.indexOf('router.', start + 20));
|
|
134
|
+
assert.equal(/if \(!version\)/.test(body), false,
|
|
135
|
+
'one ordered decision, not a null check here and a status check there');
|
|
136
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// tests/one_planning_version.mjs — exactly one version plans at a time
|
|
2
|
+
// (BV1.R04, task 1003592, goal 1000086, ADR 0250 D5).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS AT ALL. On its own the rule looks like housekeeping. It is the
|
|
5
|
+
// second half of R03: once a building version's goal set is closed, the first
|
|
6
|
+
// thing a refused caller reaches for is a NEW version to put the goal on — and
|
|
7
|
+
// the scope gate becomes decorative. One planning version means one queue for
|
|
8
|
+
// future work and no parallel escape hatch.
|
|
9
|
+
//
|
|
10
|
+
// Scope, stated so the next reader does not think it was forgotten: this guards
|
|
11
|
+
// the PLANNING slot only. Creating a `shipped` or `frozen` version is archival
|
|
12
|
+
// bookkeeping. Refusing a second BUILDING version is D5's other half and belongs
|
|
13
|
+
// to task 1003606 (R19).
|
|
14
|
+
//
|
|
15
|
+
// The rule is a pure predicate (`authorizeVersionCreate`, the `authorizeGoalCreate`
|
|
16
|
+
// precedent), so it is EXERCISED here rather than string-matched — including the
|
|
17
|
+
// shapes a live-Postgres test would never set up. The route's own write path stays
|
|
18
|
+
// covered by versions_route.mjs against real Postgres; what is checked of the route
|
|
19
|
+
// here is only the wiring, which is all the route now contains.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/one_planning_version.mjs
|
|
22
|
+
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { versionsWithStatus } = require('../modules/lifecycle/db.js');
|
|
32
|
+
const { authorizeVersionCreate } = require('../modules/lifecycle/routes/version-route-authz.js');
|
|
33
|
+
|
|
34
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
35
|
+
const V2 = { id: 'BONGOS-V2', name: 'Next' };
|
|
36
|
+
const V3 = { id: 'BONGOS-V3', name: 'After' };
|
|
37
|
+
|
|
38
|
+
function fakePool(rows) {
|
|
39
|
+
const c = makeSqlAwareClient(() => ({ rows }));
|
|
40
|
+
return { query: (sql, p) => c._client.query(sql, p), connect: c.connect, queries: c._client.queries };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- the decision, exercised ------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test('the planning slot is free → allowed', () => {
|
|
46
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: [] }), { ok: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the planning slot is taken → named 409 that says which version has it', () => {
|
|
50
|
+
const d = authorizeVersionCreate({ status: 'planning', planning: [V2] });
|
|
51
|
+
assert.equal(d.ok, false);
|
|
52
|
+
assert.equal(d.status, 409);
|
|
53
|
+
assert.equal(d.body.error, 'planning_version_exists');
|
|
54
|
+
assert.match(d.body.message, /BONGOS-V2/,
|
|
55
|
+
'a caller who has to go look up which version is one step from cutting one');
|
|
56
|
+
assert.deepEqual(d.body.planning, [V2], 'and the rows travel with the refusal');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('creating a shipped, frozen or building version is untouched', () => {
|
|
60
|
+
// Archival bookkeeping must not be caught by a rule about the future queue,
|
|
61
|
+
// even while a planning version exists.
|
|
62
|
+
for (const status of ['shipped', 'frozen', 'building']) {
|
|
63
|
+
assert.deepEqual(authorizeVersionCreate({ status, planning: [V2] }), { ok: true },
|
|
64
|
+
`'${status}' is not the planning slot`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('more than one planning version already present → still refuses, names the first', () => {
|
|
69
|
+
// The state this rule exists to prevent may already exist (the cutover is R28).
|
|
70
|
+
// The gate must not throw or wave it through on the way to fixing it.
|
|
71
|
+
const d = authorizeVersionCreate({ status: 'planning', planning: [V2, V3] });
|
|
72
|
+
assert.equal(d.body.error, 'planning_version_exists');
|
|
73
|
+
assert.equal(d.body.planning.length, 2, 'the caller sees the full mess, not a sample');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a missing or malformed planning list does not crash the gate', () => {
|
|
77
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning' }), { ok: true },
|
|
78
|
+
'omitted defaults to empty — the route only reads when status is planning');
|
|
79
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: null }), { ok: true });
|
|
80
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: [null, undefined] }), { ok: true },
|
|
81
|
+
'empty-after-filtering is empty; a falsy row must not become a phantom refusal');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the refusal is data, not a rendered string the route has to parse', () => {
|
|
85
|
+
const d = authorizeVersionCreate({ status: 'planning', planning: [V2] });
|
|
86
|
+
assert.deepEqual(Object.keys(d).sort(), ['body', 'ok', 'status']);
|
|
87
|
+
assert.ok(typeof d.body.error === 'string' && typeof d.status === 'number');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---- the read ---------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
test('versionsWithStatus binds the status and returns id + name only', async () => {
|
|
93
|
+
const pool = fakePool([V2]);
|
|
94
|
+
const out = await versionsWithStatus('planning', { pool });
|
|
95
|
+
assert.deepEqual(out, [V2]);
|
|
96
|
+
assert.match(String(pool.queries[0].sql), /SELECT id, name FROM versions WHERE status = \$1/);
|
|
97
|
+
assert.deepEqual(pool.queries[0].params, ['planning'], 'the status is bound, not interpolated');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('no planning version reads as an empty list, not a null', async () => {
|
|
101
|
+
assert.deepEqual(await versionsWithStatus('planning', { pool: fakePool([]) }), [],
|
|
102
|
+
'and the gate treats empty as allowed, closing the loop between read and decision');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---- the wiring, which is all the route now carries -------------------------
|
|
106
|
+
|
|
107
|
+
const ROUTE = (() => {
|
|
108
|
+
const s = src('modules/lifecycle/routes/versions.js');
|
|
109
|
+
const a = s.indexOf("router.post('/versions'");
|
|
110
|
+
assert.notEqual(a, -1, 'POST /versions not found');
|
|
111
|
+
const b = s.indexOf('router.', a + 20);
|
|
112
|
+
return s.slice(a, b === -1 ? s.length : b);
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
test('the route renders the decision and does not carry the rule', () => {
|
|
116
|
+
assert.match(ROUTE, /const versionDecision = authorizeVersionCreate\(\{/);
|
|
117
|
+
assert.match(ROUTE, /return res\.status\(versionDecision\.status\)\.json\(versionDecision\.body\);/);
|
|
118
|
+
assert.equal(/planning_version_exists/.test(ROUTE), false,
|
|
119
|
+
'the refusal is the gate\'s to name, not a second copy in the handler');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the route reads the planning versions ONLY when it could matter', () => {
|
|
123
|
+
// Creating a shipped version must not pay for a query about the planning queue.
|
|
124
|
+
assert.match(ROUTE, /status === 'planning' \? await db\.versionsWithStatus\('planning'\) : \[\]/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the gate runs AFTER validation and BEFORE the insert', () => {
|
|
128
|
+
const badStatus = ROUTE.indexOf("'bad_status'");
|
|
129
|
+
const exists = ROUTE.indexOf("'version_exists'");
|
|
130
|
+
const gate = ROUTE.indexOf('authorizeVersionCreate');
|
|
131
|
+
const write = ROUTE.indexOf('db.createVersion');
|
|
132
|
+
assert.ok(badStatus !== -1 && exists !== -1 && gate !== -1 && write !== -1);
|
|
133
|
+
assert.ok(badStatus < gate, 'an invalid status is its own answer, not a queue conflict');
|
|
134
|
+
assert.ok(exists < gate, 'a duplicate id keeps its own answer too');
|
|
135
|
+
assert.ok(gate < write, 'a refusal after the insert is not a refusal');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the default status is planning, so an unqualified create hits the gate', () => {
|
|
139
|
+
// The body may omit `status`. If that default ever changed, the gate would
|
|
140
|
+
// silently stop applying to the most common call.
|
|
141
|
+
assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the accepted race is described as unmitigated, because it is', () => {
|
|
145
|
+
// An earlier draft of this comment said the race was "left to a partial unique
|
|
146
|
+
// index" — which reads as though one exists. It does not, and a future reader
|
|
147
|
+
// taking that at face value would believe two concurrent creates cannot both
|
|
148
|
+
// slip through. Pinned so the honest wording cannot quietly regress.
|
|
149
|
+
assert.match(ROUTE, /RACE, ACCEPTED AND UNMITIGATED/);
|
|
150
|
+
assert.match(ROUTE, /no database\s*\n?\s*\/\/ constraint backs it/,
|
|
151
|
+
'the comment must say plainly that nothing catches it');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---- the rule is one half of a pair ----------------------------------------
|
|
155
|
+
|
|
156
|
+
test('R03 and R04 are the pair: closing the goal set, and closing the way around it', () => {
|
|
157
|
+
assert.match(src('modules/lifecycle/routes/goal-route-authz.js'), /function authorizeGoalCreate/,
|
|
158
|
+
'R03 closes a building version\'s goal set');
|
|
159
|
+
assert.equal(authorizeVersionCreate({ status: 'planning', planning: [V2] }).ok, false,
|
|
160
|
+
'and R04 closes the obvious route around it — neither is much use alone');
|
|
161
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// tests/project_invite_suggestions.mjs — WHO the creation wizard suggests
|
|
2
|
+
// inviting (BV1.R28, task 1002347, goal 1000046 criterion C6, ADR 0251).
|
|
3
|
+
//
|
|
4
|
+
// tests/project_invite_ui.mjs pins the SURFACE (how a suggestion renders and what
|
|
5
|
+
// clicking it sends). This file pins the LOOKUP behind it, and above all the two
|
|
6
|
+
// properties that are security rather than behaviour:
|
|
7
|
+
//
|
|
8
|
+
// • it reads the RECRUITING DIRECTORY, not the leaderboard — so the recruiting
|
|
9
|
+
// opt-out is honoured. The two readers differ by exactly one SQL predicate
|
|
10
|
+
// and the wrong one would hand an owner the name of every builder who asked
|
|
11
|
+
// not to be recruited (community spec D3; see scoutingListableSql).
|
|
12
|
+
// • `github_id` never leaves the module — the invite route takes a LOGIN, and
|
|
13
|
+
// the id names someone who has consented to nothing.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test --test-reporter=tap tests/project_invite_suggestions.mjs
|
|
16
|
+
|
|
17
|
+
import test from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
|
|
20
|
+
// ESM named import off the CJS module: knip cannot trace a createRequire()
|
|
21
|
+
// import, so a test-only export would read as dead code and move the ratchet.
|
|
22
|
+
import { listInviteSuggestions } from '../modules/platform-identity/invite-suggestions.js';
|
|
23
|
+
|
|
24
|
+
// A pool that records the SQL it was handed and replays canned rows.
|
|
25
|
+
function fakePool(rows) {
|
|
26
|
+
const seen = [];
|
|
27
|
+
return {
|
|
28
|
+
seen,
|
|
29
|
+
query: async (text, params) => {
|
|
30
|
+
seen.push({ text, params });
|
|
31
|
+
return { rows };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BUILDERS = [
|
|
37
|
+
{ github_id: 1, github_login: 'ada', display_name: 'Ada L', avatar_url: 'a.png', disciplines: ['engineer'], project_count: 3, total_tasks_shipped: 40, total_credits: 900 },
|
|
38
|
+
{ github_id: 2, github_login: 'grace', display_name: 'Grace H', avatar_url: 'g.png', disciplines: ['engineer'], project_count: 2, total_tasks_shipped: 30, total_credits: 800 },
|
|
39
|
+
{ github_id: 3, github_login: 'linus', display_name: null, avatar_url: null, disciplines: null, project_count: 1, total_tasks_shipped: 10, total_credits: 100 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
test('it reads the RECRUITING directory — the consent predicate is in the query it runs', async () => {
|
|
43
|
+
// Arrange
|
|
44
|
+
const pool = fakePool(BUILDERS);
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
await listInviteSuggestions({ limit: 3, pool });
|
|
48
|
+
|
|
49
|
+
// Assert — the opt-out axis (`recruiter_reach`/opt-out columns via
|
|
50
|
+
// scoutingListableSql) must appear in the SQL. A leaderboard read would join
|
|
51
|
+
// rollupVisibleSql instead and quietly include opted-out builders.
|
|
52
|
+
const sql = pool.seen[0].text;
|
|
53
|
+
assert.match(sql, /platform_identity_builder_project_activity/, 'the cross-project activity rollup, not the credits ledger');
|
|
54
|
+
assert.match(sql, /platform_identity_accounts/, 'joined to accounts for the consent predicate');
|
|
55
|
+
assert.doesNotMatch(sql, /FROM\s+builders\b/, 'never the instance leaderboard table');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('github_id never crosses the boundary — the caller gets the login, which is what an invite takes', async () => {
|
|
59
|
+
// Act
|
|
60
|
+
const out = await listInviteSuggestions({ limit: 3, pool: fakePool(BUILDERS) });
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
assert.equal(out.length, 3);
|
|
64
|
+
for (const row of out) {
|
|
65
|
+
assert.ok(!('github_id' in row), 'no github_id on a suggestion');
|
|
66
|
+
assert.ok(row.github_login, 'the login IS the address the invite route takes');
|
|
67
|
+
}
|
|
68
|
+
assert.deepEqual(Object.keys(out[0]).sort(),
|
|
69
|
+
['avatar_url', 'disciplines', 'display_name', 'github_login', 'project_count', 'total_tasks_shipped'],
|
|
70
|
+
'an explicit whitelist, so a new column on the rollup cannot leak by default');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the owner is dropped from their own suggestions, and the list still fills', async () => {
|
|
74
|
+
// Act — ada (1) is the owner asking
|
|
75
|
+
const out = await listInviteSuggestions({ limit: 2, exclude: [1], pool: fakePool(BUILDERS) });
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
assert.deepEqual(out.map((r) => r.github_login), ['grace', 'linus'],
|
|
79
|
+
'the owner is gone and the next builder moved up — the limit is still met');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the over-read is sized to the exclusion, so filtering can never return short', async () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
const pool = fakePool(BUILDERS);
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
await listInviteSuggestions({ limit: 3, exclude: [1, 2], pool });
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
assert.equal(pool.seen[0].params[0], 5, 'asked for limit + the two it may have to drop');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a limit of zero asks the database nothing at all', async () => {
|
|
94
|
+
// Arrange
|
|
95
|
+
const pool = fakePool(BUILDERS);
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
const out = await listInviteSuggestions({ limit: 0, pool });
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
assert.deepEqual(out, []);
|
|
102
|
+
assert.equal(pool.seen.length, 0, 'no seek, no query — the solo answer costs nothing');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the limit is clamped, so a caller cannot turn a suggestion into a directory dump', async () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
const pool = fakePool(BUILDERS);
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
await listInviteSuggestions({ limit: 9999, pool });
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
assert.equal(pool.seen[0].params[0], 25, 'clamped to the ceiling');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a row with no login is dropped — it cannot be invited, so it is not a suggestion', async () => {
|
|
117
|
+
// Act
|
|
118
|
+
const out = await listInviteSuggestions({
|
|
119
|
+
limit: 5,
|
|
120
|
+
pool: fakePool([{ github_id: 9, github_login: null, display_name: 'Ghost' }, ...BUILDERS]),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
assert.deepEqual(out.map((r) => r.github_login), ['ada', 'grace', 'linus']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a missing disciplines array reads as empty rather than null', async () => {
|
|
128
|
+
// Act
|
|
129
|
+
const out = await listInviteSuggestions({ limit: 5, pool: fakePool(BUILDERS) });
|
|
130
|
+
|
|
131
|
+
// Assert — linus has disciplines: null on the row
|
|
132
|
+
assert.deepEqual(out[2].disciplines, [], 'the surface can map() it without a guard');
|
|
133
|
+
});
|