@bongos/core 1.19.180 → 1.19.182

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.
@@ -0,0 +1,117 @@
1
+ // tests/idea_task_ship_credit.mjs
2
+ //
3
+ // task 1002998 (BV1.R174 / IC4, goal 1000067) — stream 1 of the ideator credit
4
+ // lane: as each task descended from an idea SHIPS, pay the idea's author
5
+ // 5 × grade-factor, once. ADR 0172. Replaces the ADR 0023 25% slice at ship.
6
+ //
7
+ // The done-when as assertions: a shipped task under a Full Idea pays
8
+ // 5 × completeness%; under a Quick idea pays 5 × the flat factor; a Full Idea
9
+ // that became a GOAL pays on EVERY one of its shipped tasks (the one-idea-many-
10
+ // tasks point); re-running never double-pays per (idea, task); cost-plus-only
11
+ // does NOT suppress it; a task with no source idea pays nothing.
12
+ //
13
+ // awardIdeaTaskShipCredit is exercised over a stub client whose credit_log is a
14
+ // set of reasons and whose lineage/idea/grade SELECT returns a scripted row —
15
+ // so the grade-factor math and the per-(idea,task) idempotency are proven
16
+ // without a DB.
17
+ //
18
+ // Run: node tests/idea_task_ship_credit.mjs
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const credits = require('../modules/economy/credits.js');
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ async function test(name, fn) {
29
+ try { await fn(); passed++; console.log(` ok ${name}`); }
30
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
31
+ }
32
+
33
+ // `source` is the {idea_id, captured_by, grade, completeness} the lineage SELECT
34
+ // returns for a task (null → no source idea). `credited` is the in-memory ledger.
35
+ function stubClient(source) {
36
+ const credited = [];
37
+ return {
38
+ credited,
39
+ async query(sql, params) {
40
+ if (/FROM ideas_task_lineage/i.test(sql)) {
41
+ return { rows: source ? [source] : [] };
42
+ }
43
+ if (/INSERT INTO credit_log/i.test(sql)) {
44
+ assert.match(sql, /builder_id = \$1 AND reason = \$4/, 'idempotency must be builder-scoped for the index');
45
+ const reason = params[3];
46
+ if (credited.some((r) => r.reason === reason)) return { rowCount: 0, rows: [] };
47
+ credited.push({ builder_id: params[0], task_id: params[1], delta: params[2], reason });
48
+ return { rowCount: 1, rows: [] };
49
+ }
50
+ return { rows: [] };
51
+ },
52
+ };
53
+ }
54
+
55
+ const ON = { ideatorCredit: true };
56
+
57
+ await test('a shipped task under a FULL idea pays 5 × completeness%', async () => {
58
+ const c = stubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 });
59
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
60
+ assert.equal(r.credited, 4, '5 × 0.80 = 4');
61
+ assert.equal(r.reason, 'paid');
62
+ assert.equal(c.credited[0].builder_id, 3);
63
+ assert.equal(c.credited[0].reason, 'idea.credit.task_ship:7:100');
64
+ });
65
+
66
+ await test('a shipped task under a QUICK idea pays 5 × the flat factor', async () => {
67
+ const c = stubClient({ idea_id: 7, captured_by: 3, grade: 'quick', completeness: null });
68
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
69
+ assert.equal(r.credited, Math.round(credits.IDEA_TASK_SHIP_BASE * credits.QUICK_IDEA_FACTOR)); // 5 × 0.6 = 3
70
+ });
71
+
72
+ await test('a full idea with 100% pays the full base; a spark falls back to the quick factor', async () => {
73
+ const full = stubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 100 });
74
+ assert.equal((await credits.awardIdeaTaskShipCredit(full, { taskId: 100, config: ON })).credited, 5);
75
+ const spark = stubClient({ idea_id: 7, captured_by: 3, grade: 'spark', completeness: null });
76
+ assert.equal((await credits.awardIdeaTaskShipCredit(spark, { taskId: 100, config: ON })).credited, 3);
77
+ });
78
+
79
+ await test('one idea → many tasks: EACH shipped task pays (the whole point of stream 1)', async () => {
80
+ // same idea 7, three different tasks — three separate credits
81
+ for (const taskId of [100, 101, 102]) {
82
+ const c = stubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 });
83
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId, config: ON });
84
+ assert.equal(r.credited, 4, `task ${taskId} pays`);
85
+ assert.equal(c.credited[0].reason, `idea.credit.task_ship:7:${taskId}`, 'distinct per task');
86
+ }
87
+ });
88
+
89
+ await test('re-running the same (idea, task) never double-pays', async () => {
90
+ const c = stubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 });
91
+ const first = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
92
+ const second = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
93
+ assert.equal(first.credited, 4);
94
+ assert.equal(second.credited, 0);
95
+ assert.equal(second.reason, 'already_paid');
96
+ assert.equal(c.credited.length, 1);
97
+ });
98
+
99
+ await test('COST-PLUS-ONLY does NOT suppress stream 1 — the ideator lane pays', async () => {
100
+ const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
101
+ assert.equal(policy.perTaskCredit, false);
102
+ const c = stubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 });
103
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: policy });
104
+ assert.equal(r.credited, 4, 'paid despite cost-plus-only');
105
+ });
106
+
107
+ await test('the lane opted out, or a task with no source idea, pays nothing', async () => {
108
+ const off = stubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 });
109
+ assert.equal((await credits.awardIdeaTaskShipCredit(off, { taskId: 100, config: { ideatorCredit: false } })).reason, 'ideator_lane_off');
110
+ const orphan = stubClient(null);
111
+ const r = await credits.awardIdeaTaskShipCredit(orphan, { taskId: 100, config: ON });
112
+ assert.equal(r.credited, 0);
113
+ assert.equal(r.reason, 'no_source_idea');
114
+ });
115
+
116
+ console.log(`\nidea_task_ship_credit: ${passed} passed, ${failed} failed`);
117
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,133 @@
1
+ // tests/profile_fields.mjs — P02, task 1002798 (goal 1000045; real DB).
2
+ //
3
+ // Proves migration 009 + the profile-fields machinery against real SQL (the
4
+ // handle_machinery.mjs precedent):
5
+ // 1. setAccountProfileFields on an UNSEEN builder creates a fail-closed
6
+ // provisional account, seeds the handle, stores bio + links
7
+ // 2. the ADR 0156 mirror: setting display_name stamps source='user', and a
8
+ // later upsertAccount re-sync (the check-in shape) keeps its hands off the
9
+ // name while github_login/avatar_url still re-sync; a never-renamed
10
+ // account still follows GitHub renames
11
+ // 3. the UPDATE branch touches ONLY the fields sent — bio alone leaves
12
+ // links/display_name alone, and never demotes profile_state or flips
13
+ // hide_stats
14
+ // 4. links REPLACE wholesale; null clears; a rejected link writes nothing
15
+ // 5. the public read (getPublicProfileByHandle) carries bio + links
16
+ //
17
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
18
+ // INTEGRATION set so the default DB-free lane never runs it.
19
+ //
20
+ // Run: DATABASE_URL=... node tests/profile_fields.mjs
21
+
22
+ import { createRequire } from 'node:module';
23
+ import { readFileSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { dirname, join } from 'node:path';
26
+ import { strict as assert } from 'node:assert';
27
+ const require = createRequire(import.meta.url);
28
+ const { Pool } = require('pg');
29
+ const pi = require('../modules/platform-identity/platform-identity.js');
30
+
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
33
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
34
+
35
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
36
+ try {
37
+ await pool.query('SELECT 1');
38
+ } catch (e) {
39
+ console.log(`profile_fields.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
40
+ await pool.end().catch(() => {});
41
+ process.exit(0);
42
+ }
43
+
44
+ // High-numbered fixture ids so a shared DB can never collide with real data.
45
+ const FRESH = 9002798001, SYNCED = 9002798002;
46
+ const ALL_IDS = [FRESH, SYNCED];
47
+
48
+ const q = (sql, params) => pool.query(sql, params);
49
+ async function cleanup() {
50
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
51
+ }
52
+
53
+ let pass = 0;
54
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
55
+ const acctOf = (id) => pi.getAccount(id, { pool });
56
+
57
+ try {
58
+ await q(migrationSql('platform_identity_001_tables.sql'));
59
+ await q(migrationSql('platform_identity_007_profile_consent.sql'));
60
+ await q(migrationSql('platform_identity_008_handle.sql'));
61
+ await q(migrationSql('platform_identity_009_profile_fields.sql'));
62
+ // Idempotence is part of what this proves.
63
+ await q(migrationSql('platform_identity_009_profile_fields.sql'));
64
+ await cleanup();
65
+
66
+ // --- 1. INSERT branch: unseen builder fills their profile ahead of time ---
67
+ const first = await pi.setAccountProfileFields({
68
+ githubId: FRESH, githubLogin: 'P02Fresh', displayName: 'GH Fresh', avatarUrl: 'https://a.example/f.png',
69
+ fields: { bio: ' Builds bongos in the cloud. ', links: { website: 'https://fresh.example', x: 'https://x.com/fresh' } },
70
+ }, { pool });
71
+ ok(first.bio === 'Builds bongos in the cloud.', 'INSERT branch stores the trimmed bio');
72
+ ok(first.links.website === 'https://fresh.example/' && first.links.x === 'https://x.com/fresh', 'INSERT branch stores normalized links');
73
+ ok(first.handle === 'p02fresh', 'the create path seeds the handle, like every other account-creating upsert');
74
+ let a = await acctOf(FRESH);
75
+ ok(a.profile_state === 'provisional', 'a profile-fields write never grants consent — fail-closed provisional');
76
+ ok(a.display_name === 'GH Fresh' && a.display_name_source === 'github', 'no display_name sent → GitHub-derived name, source stays github');
77
+
78
+ // --- 2. the ADR 0156 mirror ----------------------------------------------
79
+ const named = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'P02Fresh', fields: { display_name: 'Fresh, Renamed' } }, { pool });
80
+ ok(named.display_name === 'Fresh, Renamed', 'setting display_name writes it');
81
+ a = await acctOf(FRESH);
82
+ ok(a.display_name_source === 'user', 'and stamps display_name_source=user');
83
+
84
+ await pi.upsertAccount({ githubId: FRESH, githubLogin: 'p02fresh-renamed', displayName: 'GH Clobber', avatarUrl: 'https://a.example/new.png' }, { pool });
85
+ a = await acctOf(FRESH);
86
+ ok(a.display_name === 'Fresh, Renamed', 'a check-in re-sync does NOT clobber a user-set name');
87
+ ok(a.github_login === 'p02fresh-renamed' && a.avatar_url === 'https://a.example/new.png', 'github_login + avatar_url still re-sync unconditionally');
88
+
89
+ await pi.upsertAccount({ githubId: SYNCED, githubLogin: 'p02synced', displayName: 'GH Old' }, { pool });
90
+ await pi.upsertAccount({ githubId: SYNCED, githubLogin: 'p02synced', displayName: 'GH New' }, { pool });
91
+ a = await acctOf(SYNCED);
92
+ ok(a.display_name === 'GH New', 'a never-renamed account still follows GitHub renames');
93
+
94
+ // --- 3. the UPDATE branch touches only what was sent ----------------------
95
+ await q(`UPDATE platform_identity_accounts SET profile_state = 'active', hide_stats = true WHERE github_id = $1`, [FRESH]);
96
+ await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'Just the bio.' } }, { pool });
97
+ a = await acctOf(FRESH);
98
+ ok(a.bio === 'Just the bio.', 'bio alone updates');
99
+ ok(a.links.website === 'https://fresh.example/', 'links untouched by a bio-only write');
100
+ ok(a.display_name === 'Fresh, Renamed', 'display_name untouched by a bio-only write');
101
+ ok(a.profile_state === 'active' && a.hide_stats === true, 'profile_state + hide_stats never move on a profile-fields write');
102
+
103
+ // --- 4. links replace wholesale; null clears; rejects write nothing -------
104
+ await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { links: { discord: 'https://discord.gg/bongos' } } }, { pool });
105
+ a = await acctOf(FRESH);
106
+ ok(a.links.discord === 'https://discord.gg/bongos' && !a.links.website && !a.links.x, 'a links write replaces the whole set');
107
+
108
+ const bad = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'never lands', links: { linkedin: 'https://evil.example/in' } } }, { pool });
109
+ ok(bad.error === 'invalid_link' && bad.reason === 'wrong_host', 'a host-pinned type refuses a foreign host');
110
+ a = await acctOf(FRESH);
111
+ ok(a.bio === 'Just the bio.' && a.links.discord === 'https://discord.gg/bongos', 'a rejected write writes NOTHING — not even the valid fields beside it');
112
+
113
+ const none = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: {} }, { pool });
114
+ ok(none.error === 'no_fields', 'an empty fields object is refused, not a silent no-op');
115
+
116
+ await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: null, links: null } }, { pool });
117
+ a = await acctOf(FRESH);
118
+ ok(a.bio === null && a.links === null, 'null clears bio and links');
119
+
120
+ // --- 5. the public read carries the fields --------------------------------
121
+ await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'Public bio.', links: { website: 'https://fresh.example' } } }, { pool });
122
+ const page = await pi.getPublicProfileByHandle('p02fresh', { pool });
123
+ ok(page.account.bio === 'Public bio.' && page.account.links.website === 'https://fresh.example/', 'getPublicProfileByHandle serves bio + links');
124
+ ok(!('display_name_source' in page.account), 'display_name_source never crosses the public whitelist');
125
+
126
+ console.log(`\nprofile_fields: ${pass} checks passed`);
127
+ } catch (e) {
128
+ console.error(`\nFAIL after ${pass} checks: ${e.message}`);
129
+ process.exitCode = 1;
130
+ } finally {
131
+ await cleanup();
132
+ await pool.end().catch(() => {});
133
+ }