@bongos/core 1.20.18 → 1.20.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 +72 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0352-a-rollup-report-carries-its-as-of-in-a-header.md +38 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +6 -4
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/guild-totals.js +217 -0
- package/modules/platform-identity/guilds.js +6 -2
- package/modules/platform-identity/migrations/platform_identity_026_activity_reported_as_of.sql +27 -0
- package/modules/platform-identity/migrations/platform_identity_027_guild_totals.sql +45 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +11 -6
- package/modules/platform-identity/pollers/guild-totals.js +76 -0
- package/modules/platform-identity/routes/guilds-public.js +11 -9
- package/modules/platform-identity/routes/sso.js +18 -0
- package/modules/platform-identity/tests/platform-identity.mjs +3 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +9 -0
- package/src/bongos/auth-admission.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_as_of.mjs +172 -0
- package/tests/activity_rollup_order_db.mjs +131 -0
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/guild_totals.mjs +183 -0
- package/tests/guild_totals_db.mjs +292 -0
- package/tests/guilds_api.mjs +6 -1
- package/tests/profile_rollup_consent.mjs +2 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// tests/activity_rollup_order_db.mjs — task 1004268 (real DB).
|
|
2
|
+
//
|
|
3
|
+
// A rollup report computed EARLIER never overwrites one computed LATER. The
|
|
4
|
+
// instance stamps each report with the moment it read the totals (the
|
|
5
|
+
// Bongos-Report-As-Of header, carried here as upsertActivity's `asOf`), and the
|
|
6
|
+
// live row's ON CONFLICT DO UPDATE only fires when the incoming stamp is at least
|
|
7
|
+
// the stored one. Before this, a sign-in report and a ship report racing to the
|
|
8
|
+
// hub were last-writer-wins, so the older totals could commit last and the
|
|
9
|
+
// leaderboard would read a regression that never happened.
|
|
10
|
+
//
|
|
11
|
+
// Only a real Postgres can settle this: the guard is a WHERE on the conflict
|
|
12
|
+
// branch, and what it does with NULLs (a report from an instance too old to stamp,
|
|
13
|
+
// a row written before the column existed) and with a skipped update (does the
|
|
14
|
+
// snapshot beside it still agree with the live row?) is planner behaviour, not
|
|
15
|
+
// statement shape.
|
|
16
|
+
//
|
|
17
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
18
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
19
|
+
// never runs it.
|
|
20
|
+
//
|
|
21
|
+
// Run: DATABASE_URL=... node tests/activity_rollup_order_db.mjs
|
|
22
|
+
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { Pool } = require('pg');
|
|
30
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
31
|
+
|
|
32
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
34
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
35
|
+
|
|
36
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
37
|
+
try {
|
|
38
|
+
await pool.query('SELECT 1');
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.log(`activity_rollup_order_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
41
|
+
await pool.end().catch(() => {});
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
46
|
+
const ORDER = 90042681, LEGACY = 90042682, FUTURE = 90042683, RACE = 90042684;
|
|
47
|
+
const IDS = [ORDER, LEGACY, FUTURE, RACE];
|
|
48
|
+
const CLIENT = 't1004268-client';
|
|
49
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
50
|
+
const report = (githubId, credits, asOf) =>
|
|
51
|
+
pi.upsertActivity({ clientId: CLIENT, githubId, credits, tasksShipped: 1, karma: 0, asOf }, { pool });
|
|
52
|
+
const at = (sec) => new Date(Date.UTC(2026, 8, 30, 12, 0, sec)).toISOString();
|
|
53
|
+
|
|
54
|
+
async function cleanup() {
|
|
55
|
+
await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
56
|
+
await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
async function live(githubId) {
|
|
59
|
+
const { rows } = await q(
|
|
60
|
+
`SELECT credits, reported_as_of FROM platform_identity_builder_project_activity
|
|
61
|
+
WHERE client_id = $1 AND github_id = $2`, [CLIENT, githubId]);
|
|
62
|
+
return rows[0];
|
|
63
|
+
}
|
|
64
|
+
async function newestSnapshot(githubId) {
|
|
65
|
+
const { rows } = await q(
|
|
66
|
+
`SELECT credits FROM platform_identity_activity_snapshots
|
|
67
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at DESC, id DESC LIMIT 1`, [CLIENT, githubId]);
|
|
68
|
+
return rows[0] ? rows[0].credits : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let pass = 0;
|
|
72
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
73
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
for (const f of ['platform_identity_005_builder_activity.sql', 'platform_identity_012_recent_ships.sql',
|
|
77
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_026_activity_reported_as_of.sql']) {
|
|
78
|
+
await q(migrationSql(f));
|
|
79
|
+
}
|
|
80
|
+
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql')); // idempotent: replays cleanly
|
|
81
|
+
await cleanup();
|
|
82
|
+
|
|
83
|
+
// 1 — the race the task describes, landed in the bad order.
|
|
84
|
+
await report(ORDER, 200, at(20)); // the ship report, computed later, lands first
|
|
85
|
+
await report(ORDER, 100, at(10)); // the sign-in report, computed earlier, lands last
|
|
86
|
+
same((await live(ORDER)).credits, 200, 'an older report landing last does not overwrite the newer totals');
|
|
87
|
+
same(await newestSnapshot(ORDER), 200, 'and the snapshot history does not record the regression either');
|
|
88
|
+
await report(ORDER, 300, at(30));
|
|
89
|
+
same((await live(ORDER)).credits, 300, 'a genuinely newer report still applies');
|
|
90
|
+
await report(ORDER, 250, at(30));
|
|
91
|
+
same((await live(ORDER)).credits, 250, 'an equal stamp applies (ties go to the later arrival, as before)');
|
|
92
|
+
|
|
93
|
+
// 2 — mixed versions. An instance too old to stamp sends no as_of: last-writer-wins,
|
|
94
|
+
// exactly as before, and it clears the guard rather than leaving a stamp its numbers
|
|
95
|
+
// no longer match.
|
|
96
|
+
await report(LEGACY, 10, null);
|
|
97
|
+
same((await live(LEGACY)).reported_as_of, null, 'an unstamped report stores no stamp');
|
|
98
|
+
await report(LEGACY, 20, at(10));
|
|
99
|
+
same((await live(LEGACY)).credits, 20, 'a stamped report over an unstamped row applies');
|
|
100
|
+
await report(LEGACY, 5, null);
|
|
101
|
+
same([(await live(LEGACY)).credits, (await live(LEGACY)).reported_as_of], [5, null],
|
|
102
|
+
'an unstamped report always applies and clears the stamp (the old behaviour, for old instances)');
|
|
103
|
+
await report(LEGACY, 30, at(1));
|
|
104
|
+
same((await live(LEGACY)).credits, 30, 'after it, the next stamped report applies whatever its stamp');
|
|
105
|
+
|
|
106
|
+
// 3 — a sender whose clock runs ahead cannot freeze the row: a stamp past the hub's
|
|
107
|
+
// own now() is stored as now(), so the next honest report is not refused until then.
|
|
108
|
+
await report(FUTURE, 1, new Date(Date.now() + 365 * 86400e3).toISOString());
|
|
109
|
+
const stored = new Date((await live(FUTURE)).reported_as_of).getTime();
|
|
110
|
+
ok(stored <= Date.now() + 1000, 'a future stamp is clamped to the hub clock');
|
|
111
|
+
await report(FUTURE, 2, new Date(Date.now() + 2000).toISOString());
|
|
112
|
+
same((await live(FUTURE)).credits, 2, 'so the next report from a correct clock still applies');
|
|
113
|
+
|
|
114
|
+
// 4 — racing writers: the row lock queues them, and whatever the commit order the
|
|
115
|
+
// row ends on the report with the LATEST stamp, and the newest snapshot agrees.
|
|
116
|
+
for (let round = 0; round < 10; round++) {
|
|
117
|
+
await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = $1', [RACE]);
|
|
118
|
+
await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = $1', [RACE]);
|
|
119
|
+
const stamps = [1, 2, 3, 4, 5].sort(() => Math.random() - 0.5);
|
|
120
|
+
await Promise.all(stamps.map((s) => report(RACE, s * 100, at(s))));
|
|
121
|
+
const row = await live(RACE);
|
|
122
|
+
assert.equal(row.credits, 500, `round ${round}: the row ended on ${row.credits}, not the latest-stamped 500`);
|
|
123
|
+
assert.equal(await newestSnapshot(RACE), 500, `round ${round}: the newest snapshot disagrees with the live row`);
|
|
124
|
+
}
|
|
125
|
+
pass++; console.log(' ok - 10 rounds of five racing reports in random order: the latest stamp always wins');
|
|
126
|
+
|
|
127
|
+
console.log(`\nactivity_rollup_order_db.mjs: ${pass} passed`);
|
|
128
|
+
} finally {
|
|
129
|
+
await cleanup();
|
|
130
|
+
await pool.end().catch(() => {});
|
|
131
|
+
}
|
|
@@ -97,6 +97,8 @@ try {
|
|
|
97
97
|
// column); without it here, the upsertActivity steps fail 42703 on a fresh database.
|
|
98
98
|
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
99
99
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
100
|
+
// upsertActivity also writes reported_as_of (task 1004268) — same reason as 012 above.
|
|
101
|
+
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
|
|
100
102
|
await cleanup();
|
|
101
103
|
|
|
102
104
|
// 1 — the writer snapshots the LIVE row, and only when it changed.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/guild_totals.mjs — a guild's collective record (R26, task 1002302; ADR
|
|
2
|
+
// 0337 D4.4, superseding ADR 0336 D5).
|
|
3
|
+
//
|
|
4
|
+
// DB-free. What it holds, each easy to lose in a later edit without anything else
|
|
5
|
+
// going red:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE BAND SCALE. 1-2-5 from 10, the band floor published, 0 below the first
|
|
8
|
+
// band — exact numbers never reach a row.
|
|
9
|
+
// 2. THE NAMED CONSTANTS are the ADR's: k = 3, a 7-day refresh.
|
|
10
|
+
// 3. THE COMPUTATION COMPOSES THE SHARED PREDICATES (accountActiveSql for who may
|
|
11
|
+
// count at all, rollupVisibleSql for who is public) and never restates them;
|
|
12
|
+
// only public members' named public projects enter the project count.
|
|
13
|
+
// 4. THE WEEKLY GATE IS IN THE WRITE ITSELF, and the read is a bare SELECT.
|
|
14
|
+
// 5. THE POLLER IS REGISTERED and a failed sweep never throws.
|
|
15
|
+
//
|
|
16
|
+
// The rules that only a real planner can settle (who is counted, when k flips,
|
|
17
|
+
// the padding case, the upsert's age guard) are proved in tests/guild_totals_db.mjs.
|
|
18
|
+
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const gt = require('../modules/platform-identity/guild-totals.js');
|
|
26
|
+
const poller = require('../modules/platform-identity/pollers/guild-totals.js');
|
|
27
|
+
const { accountActiveSql, rollupVisibleSql } = require('../modules/platform-identity/account-visibility.js');
|
|
28
|
+
const { onMapSql } = require('../modules/platform-identity/projects-feed.js');
|
|
29
|
+
|
|
30
|
+
// A pool that records every statement and answers from a queue.
|
|
31
|
+
function recordingPool(responses = []) {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const queue = [...responses];
|
|
34
|
+
return {
|
|
35
|
+
calls,
|
|
36
|
+
query(text, params) {
|
|
37
|
+
calls.push({ text, params });
|
|
38
|
+
const next = queue.shift();
|
|
39
|
+
if (next instanceof Error) return Promise.reject(next);
|
|
40
|
+
return Promise.resolve(next || { rows: [], rowCount: 0 });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 1. The band scale ──
|
|
46
|
+
|
|
47
|
+
test('bandFloor publishes the 1-2-5 band floor from 10, and 0 below it', () => {
|
|
48
|
+
const cases = [
|
|
49
|
+
[0, 0], [1, 0], [9, 0], [9.99, 0],
|
|
50
|
+
[10, 10], [19, 10], [20, 20], [49, 20], [50, 50], [99, 50],
|
|
51
|
+
[100, 100], [199, 100], [200, 200], [499, 200], [500, 500], [999, 500],
|
|
52
|
+
[1000, 1000], [2500, 2000], [7777, 5000], [12345, 10000],
|
|
53
|
+
];
|
|
54
|
+
for (const [n, want] of cases) assert.equal(gt.bandFloor(n), want, `bandFloor(${n})`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('bandFloor never publishes more than it was given, and fails closed on junk', () => {
|
|
58
|
+
for (const n of [-50, -1, NaN, undefined, null, 'abc', Infinity]) {
|
|
59
|
+
assert.equal(gt.bandFloor(n), 0, `bandFloor(${String(n)}) is 0`);
|
|
60
|
+
}
|
|
61
|
+
// Postgres hands sums back as strings; the scale reads them the same.
|
|
62
|
+
assert.equal(gt.bandFloor('1234'), 1000);
|
|
63
|
+
// The columns are integer: a floor never exceeds what they can hold.
|
|
64
|
+
assert.equal(gt.bandFloor(5e12), 2000000000);
|
|
65
|
+
for (let n = 0; n < 5000; n += 7) assert.ok(gt.bandFloor(n) <= n);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── 2. The named constants ──
|
|
69
|
+
|
|
70
|
+
test('the constants are the ADR\'s own numbers', () => {
|
|
71
|
+
assert.equal(gt.NON_PUBLIC_MIN_COUNTED, 3, 'k = 3 (ADR 0337 D4.4)');
|
|
72
|
+
assert.equal(gt.TOTALS_REFRESH_DAYS, 7, 'at most weekly');
|
|
73
|
+
assert.deepEqual(gt.TOTALS_BAND_MANTISSAS, [1, 2, 5]);
|
|
74
|
+
assert.equal(gt.TOTALS_FIRST_BAND, 10);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── 3. The computation ──
|
|
78
|
+
|
|
79
|
+
test('who counts is composed from the shared predicates, never restated', async () => {
|
|
80
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
81
|
+
await gt.computeGuildSums(['7', '8'], { pool });
|
|
82
|
+
const { text, params } = pool.calls[0];
|
|
83
|
+
assert.ok(text.includes(accountActiveSql('pa')), 'an un-consented account never counts');
|
|
84
|
+
assert.ok(text.includes(`(gm.shown_publicly AND ${rollupVisibleSql('pa')}) AS is_public`),
|
|
85
|
+
'public = shown on this guild\'s page AND the account\'s own record is public');
|
|
86
|
+
assert.match(text, /AND gm\.counted_in_totals/, 'the per-guild switch is honoured');
|
|
87
|
+
assert.match(text, /WHERE m\.is_public OR k\.non_public >= \$2/,
|
|
88
|
+
'non-public numbers enter only at or above k');
|
|
89
|
+
assert.deepEqual(params, [['7', '8'], 3], 'k is bound from the named constant');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('only public members\' named public projects enter the project count', async () => {
|
|
93
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
94
|
+
await gt.computeGuildSums(['7'], { pool });
|
|
95
|
+
const projs = pool.calls[0].text.split('projs AS')[1];
|
|
96
|
+
assert.ok(projs.includes(`WHERE c.is_public AND ${onMapSql('p')} AND p.visibility = 'public'`),
|
|
97
|
+
'ADR 0336 D5\'s union rule: never a non-public member\'s projects, never a non-public project');
|
|
98
|
+
assert.match(projs, /count\(DISTINCT p\.id\)/, 'a project two members share counts once');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an empty batch asks the database nothing', async () => {
|
|
102
|
+
const pool = recordingPool();
|
|
103
|
+
assert.deepEqual(await gt.computeGuildSums([], { pool }), []);
|
|
104
|
+
assert.equal(pool.calls.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── 4. The weekly gate, and a read that only reads ──
|
|
108
|
+
|
|
109
|
+
test('refresh stores band floors only, and re-asserts the week in its own upsert', async () => {
|
|
110
|
+
const pool = recordingPool([
|
|
111
|
+
{ rows: [{ id: '7' }, { id: '8' }] },
|
|
112
|
+
{ rows: [
|
|
113
|
+
{ guild_id: '7', credits: '1234', works_shipped: '57', karma: '-3', projects: '2' },
|
|
114
|
+
{ guild_id: '8', credits: '0', works_shipped: '0', karma: '0', projects: '0' },
|
|
115
|
+
] },
|
|
116
|
+
{ rows: [], rowCount: 2 },
|
|
117
|
+
]);
|
|
118
|
+
const out = await gt.refreshStaleGuildTotals({ limit: 50 }, { pool });
|
|
119
|
+
assert.deepEqual(out, { scanned: 2, refreshed: 2 });
|
|
120
|
+
|
|
121
|
+
const [pick, , upsert] = pool.calls;
|
|
122
|
+
assert.match(pick.text, /t\.guild_id IS NULL OR t\.computed_at <= now\(\) - make_interval\(days => \$1::int\)/,
|
|
123
|
+
'due = never computed, or at least a week old');
|
|
124
|
+
assert.match(pick.text, /ORDER BY t\.computed_at ASC NULLS FIRST/, 'stalest first');
|
|
125
|
+
assert.deepEqual(pick.params, [7, 50]);
|
|
126
|
+
|
|
127
|
+
assert.match(upsert.text, /ON CONFLICT \(guild_id\) DO UPDATE[\s\S]*WHERE t\.computed_at <= now\(\) - make_interval\(days => \$6::int\)/,
|
|
128
|
+
'a racing sweep cannot refresh a guild twice in a week');
|
|
129
|
+
assert.match(upsert.text, /WHERE EXISTS \(SELECT 1 FROM platform_identity_guilds g WHERE g\.id = u\.guild_id\)/,
|
|
130
|
+
'a guild dissolved mid-sweep is skipped, not an FK failure for the batch');
|
|
131
|
+
assert.deepEqual(upsert.params, [['7', '8'], [1000, 0], [50, 0], [0, 0], [0, 0], 7],
|
|
132
|
+
'only band floors are written — 1234 credits is stored as 1000, never 1234');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('nothing due → no computation and no write', async () => {
|
|
136
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
137
|
+
assert.deepEqual(await gt.refreshStaleGuildTotals({}, { pool }), { scanned: 0, refreshed: 0 });
|
|
138
|
+
assert.equal(pool.calls.length, 1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the read is one SELECT of the stored record, and null before the first refresh', async () => {
|
|
142
|
+
const at = new Date('2026-09-28T00:00:00Z');
|
|
143
|
+
const pool = recordingPool([
|
|
144
|
+
{ rows: [{ credits: 1000, works_shipped: 50, karma: 0, projects: 2, computed_at: at }] },
|
|
145
|
+
{ rows: [] },
|
|
146
|
+
]);
|
|
147
|
+
assert.deepEqual(await gt.readGuildTotals('7', { pool }),
|
|
148
|
+
{ credits: 1000, works_shipped: 50, karma: 0, projects: 2, as_of: at });
|
|
149
|
+
assert.equal(await gt.readGuildTotals('8', { pool }), null);
|
|
150
|
+
for (const c of pool.calls) {
|
|
151
|
+
assert.match(c.text.trim(), /^SELECT credits, works_shipped, karma, projects, computed_at\s+FROM platform_identity_guild_totals\s+WHERE guild_id = \$1$/);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('the migration stores no exact sum and no member count', () => {
|
|
156
|
+
const sql = readFileSync(new URL('../modules/platform-identity/migrations/platform_identity_027_guild_totals.sql', import.meta.url), 'utf8');
|
|
157
|
+
const table = sql.split('CREATE TABLE IF NOT EXISTS platform_identity_guild_totals (')[1].split(');')[0];
|
|
158
|
+
const cols = table.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter((c) => /^[a-z_]+$/.test(c));
|
|
159
|
+
assert.deepEqual(cols, ['guild_id', 'credits', 'works_shipped', 'karma', 'projects', 'computed_at'],
|
|
160
|
+
'a counted-member count, or a flag saying non-public numbers are in, would reveal how many non-public members a guild has');
|
|
161
|
+
assert.match(table, /REFERENCES platform_identity_guilds\(id\) ON DELETE CASCADE/, 'a dissolved guild takes its record with it');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── 5. The poller ──
|
|
165
|
+
|
|
166
|
+
test('the poller is registered on the module and is hourly, with the weekly limit per row', () => {
|
|
167
|
+
const manifest = JSON.parse(readFileSync(new URL('../modules/platform-identity/module.json', import.meta.url), 'utf8'));
|
|
168
|
+
assert.ok(manifest.contributes.pollers.includes('guild-totals'));
|
|
169
|
+
assert.equal(poller.SWEEP_INTERVAL_MS, 60 * 60 * 1000);
|
|
170
|
+
assert.equal(typeof poller.start, 'function');
|
|
171
|
+
assert.equal(typeof poller.stop, 'function');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a failed sweep is logged and swallowed, never thrown', async () => {
|
|
175
|
+
const pool = recordingPool([new Error('db down')]);
|
|
176
|
+
assert.equal(await poller.sweepOnce({ pool }), null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a sweep hands the injected pool through', async () => {
|
|
180
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
181
|
+
assert.deepEqual(await poller.sweepOnce({ pool }), { scanned: 0, refreshed: 0 });
|
|
182
|
+
assert.equal(pool.calls.length, 1);
|
|
183
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// tests/guild_totals_db.mjs — a guild's collective record against REAL SQL
|
|
2
|
+
// (R26, task 1002302; ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// Why a real database: who is counted is a join over three predicates and a
|
|
5
|
+
// FILTERed count, the k gate is a CTE comparison, and the weekly limit is a WHERE
|
|
6
|
+
// on an upsert's conflict branch — whose re-evaluation against the row a racing
|
|
7
|
+
// writer just committed only a real Postgres settles. The DB-free sibling,
|
|
8
|
+
// tests/guild_totals.mjs, holds the band scale and the statement shapes.
|
|
9
|
+
//
|
|
10
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
11
|
+
// INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
|
|
12
|
+
// high-numbered and every guild slug starts r26-, so a shared DB is safe; all
|
|
13
|
+
// fixtures are deleted before and after.
|
|
14
|
+
//
|
|
15
|
+
// Run: DATABASE_URL=... node tests/guild_totals_db.mjs
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { Pool } = require('pg');
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
28
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
29
|
+
|
|
30
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
31
|
+
try {
|
|
32
|
+
await pool.query('SELECT 1');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`guild_totals_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
35
|
+
await pool.end().catch(() => {});
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const gt = require('../modules/platform-identity/guild-totals.js');
|
|
40
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
41
|
+
|
|
42
|
+
// The cast. Credits are powers of two where it matters, so every exact sum below
|
|
43
|
+
// names exactly which members went into it.
|
|
44
|
+
const OWN = 9002302001; // public, shown — the guild's owner
|
|
45
|
+
const PUB = 9002302002; // public, shown
|
|
46
|
+
const HID1 = 9002302003; // hides their numbers
|
|
47
|
+
const HID2 = 9002302004; // hides their numbers
|
|
48
|
+
const HID3 = 9002302005; // hides their numbers
|
|
49
|
+
const PRIV = 9002302006; // private account
|
|
50
|
+
const UNSHOWN = 9002302007; // public account, not shown on this guild's page
|
|
51
|
+
const OFF = 9002302008; // public, shown, switched counting OFF
|
|
52
|
+
const PROV = 9002302009; // provisional — never consented
|
|
53
|
+
const FAKE1 = 9002302010; // the padding case: private, numbers the owner knows
|
|
54
|
+
const FAKE2 = 9002302011;
|
|
55
|
+
const TARGET = 9002302012; // hides their numbers
|
|
56
|
+
const ALL_IDS = [OWN, PUB, HID1, HID2, HID3, PRIV, UNSHOWN, OFF, PROV, FAKE1, FAKE2, TARGET];
|
|
57
|
+
|
|
58
|
+
const CLIENT_A = 'r26-client-a'; // → a public project on the map
|
|
59
|
+
const CLIENT_B = 'r26-client-b'; // → another public project on the map
|
|
60
|
+
const CLIENT_C = 'r26-client-c'; // → a PRIVATE project
|
|
61
|
+
const ORIGIN = { [CLIENT_A]: 'https://r26-a.example', [CLIENT_B]: 'https://r26-b.example', [CLIENT_C]: 'https://r26-c.example' };
|
|
62
|
+
|
|
63
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
64
|
+
async function cleanup() {
|
|
65
|
+
await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r26-%'`).catch(() => {});
|
|
66
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
67
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin LIKE 'https://r26-%'`).catch(() => {});
|
|
68
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE client_id LIKE 'r26-%'`).catch(() => {});
|
|
69
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let pass = 0;
|
|
73
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
74
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
75
|
+
|
|
76
|
+
async function account(id, handle, { visibility = 'public', hide = false, state = 'active' } = {}) {
|
|
77
|
+
await q(
|
|
78
|
+
`INSERT INTO platform_identity_accounts
|
|
79
|
+
(github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility, hide_stats)
|
|
80
|
+
VALUES ($1, $2, $2, $2, $3, CASE WHEN $3 = 'active' THEN now() END, $4, $5)`,
|
|
81
|
+
[id, handle, state, visibility, hide],
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
async function activity(id, client, credits, ships = 0, karma = 0) {
|
|
85
|
+
await q(
|
|
86
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma)
|
|
87
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
88
|
+
[client, id, credits, ships, karma],
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
async function guild(slug, visibility = 'public') {
|
|
92
|
+
const { rows } = await q(
|
|
93
|
+
`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ($1, $1, $2) RETURNING id::text AS id`,
|
|
94
|
+
[slug, visibility],
|
|
95
|
+
);
|
|
96
|
+
return rows[0].id;
|
|
97
|
+
}
|
|
98
|
+
async function member(gid, id, { kind = 'member', shown = true, counted = true } = {}) {
|
|
99
|
+
await q(
|
|
100
|
+
`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly, counted_in_totals)
|
|
101
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
102
|
+
[gid, id, kind, shown, counted],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const sums = async (gid) => {
|
|
106
|
+
const [r] = await gt.computeGuildSums([gid], { pool });
|
|
107
|
+
return { credits: Number(r.credits), works_shipped: Number(r.works_shipped), karma: Number(r.karma), projects: Number(r.projects) };
|
|
108
|
+
};
|
|
109
|
+
const stored = async (gid) => (await q(
|
|
110
|
+
`SELECT credits, works_shipped, karma, projects, computed_at FROM platform_identity_guild_totals WHERE guild_id = $1`, [gid],
|
|
111
|
+
)).rows[0] || null;
|
|
112
|
+
const backdate = (gid, days) => q(
|
|
113
|
+
`UPDATE platform_identity_guild_totals SET computed_at = now() - make_interval(days => $2::int) WHERE guild_id = $1`, [gid, days],
|
|
114
|
+
);
|
|
115
|
+
// Only this file's guilds: a shared DB may hold other guilds the sweep would also pick.
|
|
116
|
+
const refresh = () => gt.refreshStaleGuildTotals({ limit: 500 }, { pool });
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Every table the computation reads, all idempotent: accounts + consent, handle,
|
|
120
|
+
// privacy, terms; clients, projects, their visibility and the door stamp; the
|
|
121
|
+
// activity rollup; guilds; and this task's snapshot table.
|
|
122
|
+
for (const f of [
|
|
123
|
+
'platform_identity_001_tables.sql', 'platform_identity_004_projects.sql',
|
|
124
|
+
'platform_identity_005_builder_activity.sql', 'platform_identity_007_profile_consent.sql',
|
|
125
|
+
'platform_identity_008_handle.sql', 'platform_identity_011_project_visibility.sql',
|
|
126
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
127
|
+
'platform_identity_020_visibility_pull.sql', 'platform_identity_025_guilds.sql',
|
|
128
|
+
'platform_identity_027_guild_totals.sql',
|
|
129
|
+
]) await q(migrationSql(f));
|
|
130
|
+
await q(migrationSql('platform_identity_027_guild_totals.sql')); // re-runnable
|
|
131
|
+
await cleanup();
|
|
132
|
+
|
|
133
|
+
await account(OWN, 'r26own');
|
|
134
|
+
await account(PUB, 'r26pub');
|
|
135
|
+
await account(HID1, 'r26hid1', { hide: true });
|
|
136
|
+
await account(HID2, 'r26hid2', { hide: true });
|
|
137
|
+
await account(HID3, 'r26hid3', { hide: true });
|
|
138
|
+
await account(PRIV, 'r26priv', { visibility: 'private' });
|
|
139
|
+
await account(UNSHOWN, 'r26unshown');
|
|
140
|
+
await account(OFF, 'r26off');
|
|
141
|
+
await account(PROV, 'r26prov', { state: 'provisional' });
|
|
142
|
+
await account(FAKE1, 'r26fake1', { visibility: 'private' });
|
|
143
|
+
await account(FAKE2, 'r26fake2', { visibility: 'private' });
|
|
144
|
+
await account(TARGET, 'r26target', { hide: true });
|
|
145
|
+
|
|
146
|
+
for (const c of [CLIENT_A, CLIENT_B, CLIENT_C]) {
|
|
147
|
+
await q(`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin) VALUES ($1, 'x', $1, $2)`, [c, ORIGIN[c]]);
|
|
148
|
+
}
|
|
149
|
+
await q(
|
|
150
|
+
`INSERT INTO platform_identity_projects (origin, name, description, visibility, visibility_source)
|
|
151
|
+
VALUES ($1, 'A', 'a public project', 'public', 'control-plane'),
|
|
152
|
+
($2, 'B', 'another public project', 'public', 'control-plane'),
|
|
153
|
+
($3, 'C', 'a private project', 'private', 'control-plane')`,
|
|
154
|
+
[ORIGIN[CLIENT_A], ORIGIN[CLIENT_B], ORIGIN[CLIENT_C]],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await activity(OWN, CLIENT_A, 1, 1, 1);
|
|
158
|
+
await activity(OWN, CLIENT_C, 2, 1, 1); // a public member on a private project
|
|
159
|
+
await activity(PUB, CLIENT_A, 4, 1, 1); // shares project A with OWN
|
|
160
|
+
await activity(HID1, CLIENT_B, 8, 1, 1); // the only builder on project B
|
|
161
|
+
await activity(HID2, CLIENT_A, 16, 1, 1);
|
|
162
|
+
await activity(HID3, CLIENT_A, 32, 1, 1);
|
|
163
|
+
await activity(PRIV, CLIENT_A, 64, 1, 1);
|
|
164
|
+
await activity(UNSHOWN, CLIENT_A, 128, 1, 1);
|
|
165
|
+
await activity(OFF, CLIENT_A, 256, 1, 1);
|
|
166
|
+
await activity(PROV, CLIENT_A, 512, 1, 1);
|
|
167
|
+
|
|
168
|
+
// --- 1. who is counted, and the k gate (ADR 0337 D4.4) --------------------
|
|
169
|
+
const g1 = await guild('r26-one');
|
|
170
|
+
await member(g1, OWN, { kind: 'owner' });
|
|
171
|
+
await member(g1, PUB);
|
|
172
|
+
await member(g1, HID1);
|
|
173
|
+
await member(g1, HID2);
|
|
174
|
+
await member(g1, OFF, { counted: false });
|
|
175
|
+
await member(g1, PROV);
|
|
176
|
+
same((await sums(g1)).credits, 1 + 2 + 4,
|
|
177
|
+
'two counted non-public members (< k) → the totals add up the public members alone; OFF and PROV never count');
|
|
178
|
+
|
|
179
|
+
await member(g1, HID3);
|
|
180
|
+
same((await sums(g1)).credits, 1 + 2 + 4 + 8 + 16 + 32,
|
|
181
|
+
'a third counted non-public member reaches k = 3 → every counted member\'s numbers enter');
|
|
182
|
+
same((await sums(g1)).works_shipped, 6, 'works shipped sums over the same members\' rows (OWN has two)');
|
|
183
|
+
|
|
184
|
+
const g2 = await guild('r26-two');
|
|
185
|
+
await member(g2, OWN, { kind: 'owner' });
|
|
186
|
+
await member(g2, PRIV);
|
|
187
|
+
await member(g2, UNSHOWN, { shown: false });
|
|
188
|
+
await member(g2, HID1);
|
|
189
|
+
same((await sums(g2)).credits, 1 + 2 + 64 + 128 + 8,
|
|
190
|
+
'a private account and a member not shown on the page are non-public too, and count toward k');
|
|
191
|
+
await q(`UPDATE platform_identity_guild_members SET counted_in_totals = false WHERE guild_id = $1 AND github_id = $2`, [g2, PRIV]);
|
|
192
|
+
same((await sums(g2)).credits, 1 + 2,
|
|
193
|
+
'one member switching off drops them AND can drop the guild below k, taking every non-public number out');
|
|
194
|
+
|
|
195
|
+
// --- 2. projects: public members, named public projects, distinct ---------
|
|
196
|
+
same((await sums(g1)).projects, 1,
|
|
197
|
+
'project A once (OWN and PUB share it); never private project C; never HID1\'s project B, even with k met');
|
|
198
|
+
|
|
199
|
+
// --- 3. the stored record: band floors only, and a read that only reads ----
|
|
200
|
+
same(await guilds.getPublicGuild('r26-one', { pool }).then((r) => r.totals), null,
|
|
201
|
+
'before the first refresh the public read carries no totals — and computes none');
|
|
202
|
+
same(await stored(g1), null, 'a public read wrote nothing');
|
|
203
|
+
|
|
204
|
+
await refresh();
|
|
205
|
+
const first = await stored(g1);
|
|
206
|
+
same({ credits: first.credits, works_shipped: first.works_shipped, karma: first.karma, projects: first.projects },
|
|
207
|
+
{ credits: 50, works_shipped: 0, karma: 0, projects: 0 },
|
|
208
|
+
'63 credits is stored as its band floor 50; 6 works and 6 karma are under the first band (0)');
|
|
209
|
+
const pub = await guilds.getPublicGuild('r26-one', { pool });
|
|
210
|
+
same(pub.totals, { credits: 50, works_shipped: 0, karma: 0, projects: 0, as_of: first.computed_at },
|
|
211
|
+
'the public read publishes the stored band floors and when they were computed');
|
|
212
|
+
same(pub.size, 6,
|
|
213
|
+
'size stays the public roster\'s length: OWN, PUB, OFF and the three who hide their numbers (hide_stats hides numbers, never the name — ADR 0336 D4 §2); never PROV');
|
|
214
|
+
|
|
215
|
+
// --- 4. at most weekly -----------------------------------------------------
|
|
216
|
+
await activity(OWN, CLIENT_B, 1000);
|
|
217
|
+
for (let i = 0; i < 5; i++) await guilds.getPublicGuild('r26-one', { pool });
|
|
218
|
+
await refresh();
|
|
219
|
+
same((await stored(g1)).credits, 50,
|
|
220
|
+
'a change inside the week moves nothing: not five reads, not another sweep');
|
|
221
|
+
|
|
222
|
+
await backdate(g1, 6);
|
|
223
|
+
await refresh();
|
|
224
|
+
same((await stored(g1)).credits, 50, 'six days old is still inside the week');
|
|
225
|
+
|
|
226
|
+
await backdate(g1, 7);
|
|
227
|
+
const [a, b] = await Promise.all([refresh(), refresh()]);
|
|
228
|
+
same((await stored(g1)).credits, 1000, 'a week old → refreshed: 1063 credits is now the 1000 band');
|
|
229
|
+
const racedG1 = [a, b].filter((r) => r.refreshed > 0).length;
|
|
230
|
+
ok(racedG1 >= 1, 'at least one sweep refreshed the due guild');
|
|
231
|
+
const oneRow = await q(`SELECT count(*)::int AS n FROM platform_identity_guild_totals WHERE guild_id = $1`, [g1]);
|
|
232
|
+
same(oneRow.rows[0].n, 1, 'one row per guild');
|
|
233
|
+
|
|
234
|
+
// The upsert's own guard, without the pick step's help: a sweep that picked the
|
|
235
|
+
// guild while it was due, and lost the race to one that committed first, must
|
|
236
|
+
// update nothing. The wrapper plays the winning sweep between this one's pick
|
|
237
|
+
// and its write.
|
|
238
|
+
await backdate(g1, 7);
|
|
239
|
+
const losingPool = {
|
|
240
|
+
async query(text, params) {
|
|
241
|
+
const out = await pool.query(text, params);
|
|
242
|
+
if (/LEFT JOIN platform_identity_guild_totals t ON t\.guild_id = g\.id/.test(text)) {
|
|
243
|
+
await q(`UPDATE platform_identity_guild_totals SET credits = 20, computed_at = now() WHERE guild_id = $1`, [g1]);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
await gt.refreshStaleGuildTotals({ limit: 500 }, { pool: losingPool });
|
|
249
|
+
same((await stored(g1)).credits, 20,
|
|
250
|
+
'the age check lives in the write: the losing sweep leaves the winner\'s record alone');
|
|
251
|
+
|
|
252
|
+
// --- 5. unlisted, and dissolve --------------------------------------------
|
|
253
|
+
const g3 = await guild('r26-quiet', 'unlisted');
|
|
254
|
+
await member(g3, OWN, { kind: 'owner', shown: false });
|
|
255
|
+
await refresh();
|
|
256
|
+
ok(!!(await stored(g3)), 'an unlisted guild has a record too (ready for a flip to public)…');
|
|
257
|
+
same(await guilds.getPublicGuild('r26-quiet', { pool }), null, '…but no public read reaches it');
|
|
258
|
+
await q(`DELETE FROM platform_identity_guilds WHERE id = $1`, [g3]);
|
|
259
|
+
same(await stored(g3), null, 'dissolving a guild takes its record with it (FK cascade)');
|
|
260
|
+
|
|
261
|
+
// --- 6. the honest limit, DOCUMENTED, not fixed (ADR 0337 D4.4) -----------
|
|
262
|
+
// A guild owner who pads with two accounts whose numbers they already know
|
|
263
|
+
// makes a real non-public member the third, and the total then carries that
|
|
264
|
+
// member. Accepted by the owner on 2026-09-25: the notice on the join card and
|
|
265
|
+
// the per-guild switch are the defence, not this computation. If this block
|
|
266
|
+
// ever goes red because the padding stopped working, the rule changed — update
|
|
267
|
+
// ADR 0337 D4.4 before this test.
|
|
268
|
+
await activity(TARGET, CLIENT_A, 777);
|
|
269
|
+
const own = 1 + 2 + 1000; // OWN's three rows, after section 4's report
|
|
270
|
+
const pad = await guild('r26-pad');
|
|
271
|
+
await member(pad, OWN, { kind: 'owner' });
|
|
272
|
+
await member(pad, FAKE1);
|
|
273
|
+
await member(pad, FAKE2);
|
|
274
|
+
same((await sums(pad)).credits, own, 'the two fakes alone are below k');
|
|
275
|
+
await member(pad, TARGET);
|
|
276
|
+
same((await sums(pad)).credits, own + 777,
|
|
277
|
+
'PADDING WORKS (accepted): the target becomes the third non-public member and enters the sum');
|
|
278
|
+
await q(`UPDATE platform_identity_guild_members SET counted_in_totals = false WHERE guild_id = $1 AND github_id = $2`, [pad, TARGET]);
|
|
279
|
+
same((await sums(pad)).credits, own, 'the target\'s answer is the switch: off, and they are out');
|
|
280
|
+
|
|
281
|
+
// --- 7. karma below zero stays inside the column's floor ------------------
|
|
282
|
+
const neg = await guild('r26-neg');
|
|
283
|
+
await member(neg, PUB, { kind: 'owner' });
|
|
284
|
+
await q(`UPDATE platform_identity_builder_project_activity SET karma = -40 WHERE github_id = $1`, [PUB]);
|
|
285
|
+
await refresh();
|
|
286
|
+
same((await stored(neg)).karma, 0, 'a negative sum publishes as 0, never below the CHECK');
|
|
287
|
+
|
|
288
|
+
console.log(`\nguild_totals_db.mjs: ${pass} passed`);
|
|
289
|
+
} finally {
|
|
290
|
+
await cleanup();
|
|
291
|
+
await pool.end();
|
|
292
|
+
}
|
package/tests/guilds_api.mjs
CHANGED
|
@@ -263,8 +263,13 @@ test('public read: the roster is shown_publicly AND the shared existence predica
|
|
|
263
263
|
});
|
|
264
264
|
const res = await call('GET', '/api/bongos/guilds/Guild-A');
|
|
265
265
|
assert.equal(res.status, 200);
|
|
266
|
-
assert.deepEqual(Object.keys(res.json).sort(), ['guild', 'members', 'size']);
|
|
266
|
+
assert.deepEqual(Object.keys(res.json).sort(), ['guild', 'members', 'size', 'totals']);
|
|
267
267
|
assert.equal(res.json.size, 2);
|
|
268
|
+
assert.equal(res.json.totals, null, 'no stored record yet → no totals, never a computed stand-in');
|
|
269
|
+
assert.ok(calls.some((c) => /FROM platform_identity_guild_totals\s+WHERE guild_id = \$1/.test(c.text)),
|
|
270
|
+
'the totals are READ from the weekly snapshot');
|
|
271
|
+
assert.ok(calls.every((c) => !/INSERT|UPDATE|platform_identity_builder_project_activity/.test(c.text)),
|
|
272
|
+
'the public read never computes or writes the totals (ADR 0337 D4.4: never on a read)');
|
|
268
273
|
assert.equal(res.json.members.length, 2);
|
|
269
274
|
assert.ok(!('id' in res.json.guild), 'the row id stays internal');
|
|
270
275
|
assert.ok(!/github_id|github_login/.test(res.text), 'no account id or login crosses');
|
|
@@ -100,6 +100,8 @@ try {
|
|
|
100
100
|
// before the fixtures, so its one-shot grandfather never sees LEGACY below.
|
|
101
101
|
await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
|
|
102
102
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
103
|
+
// upsertActivity also writes reported_as_of (026, task 1004268): 42703 without it.
|
|
104
|
+
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
|
|
103
105
|
await cleanup();
|
|
104
106
|
|
|
105
107
|
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|