@bongos/core 1.20.58 → 1.20.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.bongos-core.json +80 -40
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/api/openapi.json +9 -3
  4. package/docs/api-reference.md +1 -1
  5. package/docs/architecture.md +1 -0
  6. package/docs/copy-inventory.md +1 -1
  7. package/docs/copy-registry.json +9 -9
  8. package/docs/module-api-changelog.md +2 -0
  9. package/docs/page-readings.json +30 -30
  10. package/migrations/core_268_builders_main_discipline.sql +35 -0
  11. package/modules/copy-desk/artist-stats.js +56 -0
  12. package/modules/copy-desk/module.json +1 -1
  13. package/modules/copy-desk/page-status.js +39 -0
  14. package/modules/copy-desk/routes/copy-desk.js +7 -16
  15. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  16. package/modules/economy/credits-by-craft.js +208 -0
  17. package/modules/economy/reward.js +8 -0
  18. package/modules/hall-ui/public/hall-render.js +2 -2
  19. package/modules/hall-ui/public/profile.js +1 -1
  20. package/modules/ideas/ideator-stats.js +149 -0
  21. package/modules/ideas/module.json +2 -1
  22. package/modules/ideas/routes/inbox.js +7 -0
  23. package/modules/lifecycle/activity.js +5 -2
  24. package/modules/lifecycle/db-analytics.js +4 -2
  25. package/modules/lifecycle/db-tasks.js +49 -1
  26. package/modules/lifecycle/db.js +3 -2
  27. package/modules/lifecycle/page-tweak-reads.js +23 -5
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +6 -0
  31. package/scripts/gds/run-unit-tests.js +5 -0
  32. package/src/bongos/db.js +44 -6
  33. package/src/bongos/role-stats.js +155 -0
  34. package/src/bongos/routes/builders.js +15 -2
  35. package/src/bongos/routes/me.js +11 -3
  36. package/src/module-api.js +1 -1
  37. package/tests/copy_desk_page_reads.mjs +1 -1
  38. package/tests/fixtures/page-ledger.mjs +1 -1
  39. package/tests/main_discipline.mjs +81 -0
  40. package/tests/profile_role_stats.mjs +416 -0
  41. package/tests/role_stats_db.mjs +237 -0
  42. package/tests/tweak_replaces_artist_review.mjs +1 -1
@@ -8465,5 +8465,11 @@
8465
8465
  "id": "1004423",
8466
8466
  "text": "A project that is being founded now has a genesis home at the top of its hall. The founder settles a plan in one click (each stage's length, the starting government by its real name with a plain explanation, and how many passe"
8467
8467
  }
8468
+ ],
8469
+ "1.20.59": [
8470
+ {
8471
+ "id": "1004433",
8472
+ "text": "The profile now knows each person by their own work. An artist's page counts pages approved, how many pages of each part of the site they have tweaked, and what pages earned them; an ideator's counts thoughts filed, how many b"
8473
+ }
8468
8474
  ]
8469
8475
  }
@@ -155,6 +155,11 @@ const INTEGRATION = new Set([
155
155
  // predicate still returns 0 so the bug itself stays executable. Same
156
156
  // always-rolled-back transaction discipline as the entry above.
157
157
  'session_earnings_mirror_db',
158
+ // task 1004433: the credits-by-craft split and the per-person earnings
159
+ // fragment against real SQL. Both are questions about which ledger rows a LIKE
160
+ // pattern and a LATERAL unnest reach, which a stubbed client cannot answer.
161
+ // One transaction, always rolled back.
162
+ 'role_stats_db',
158
163
  // RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
159
164
  // 1003091. A curated set keyed by a stem that no longer exists silently stops
160
165
  // covering its file: both of these dropped OUT of the integration set and INTO the
package/src/bongos/db.js CHANGED
@@ -149,7 +149,27 @@ async function linkAnthropicAccount(builderId, { email, userId }) {
149
149
  // updated builder row, or throws { code: 'invalid_discipline', bad } if any
150
150
  // element is not offered by this instance.
151
151
 
152
- async function updateBuilderDisciplines(builderId, disciplines) {
152
+ // `mainDiscipline` (task 1004433, core_268) is the builder's own pick of MAIN
153
+ // role, which the canonical-order set cannot carry. Three answers:
154
+ // undefined not mentioned: the stored main role is KEPT while it is still in
155
+ // the new set, and cleared when the builder drops that craft (a main
156
+ // role they no longer practise would lead their profile otherwise);
157
+ // null cleared on purpose;
158
+ // a string set, and it must be one of the crafts in the new set
159
+ // (`invalid_main_discipline`): a main role is one of your roles.
160
+ // The write, spelled once so tests/role_stats_db.mjs runs the very statement
161
+ // against a real Postgres: $3 says whether the caller named a main role at all,
162
+ // $4 is that role (or null to clear it).
163
+ const UPDATE_BUILDER_DISCIPLINES_SQL =
164
+ `UPDATE builders
165
+ SET preferred_disciplines = $2,
166
+ main_discipline = CASE WHEN $3::boolean THEN $4::text
167
+ WHEN main_discipline = ANY($2::text[]) THEN main_discipline
168
+ ELSE NULL END
169
+ WHERE id = $1
170
+ RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, karma, created_at, rank, preferred_disciplines, main_discipline`;
171
+
172
+ async function updateBuilderDisciplines(builderId, disciplines, { mainDiscipline } = {}) {
153
173
  const { enabledDisciplines } = require('../modules');
154
174
  const offered = enabledDisciplines();
155
175
  const list = Array.isArray(disciplines) ? disciplines : [];
@@ -161,12 +181,28 @@ async function updateBuilderDisciplines(builderId, disciplines) {
161
181
  throw err;
162
182
  }
163
183
  const normalized = offered.filter((d) => list.includes(d));
184
+ if (typeof mainDiscipline === 'string' && !normalized.includes(mainDiscipline)) {
185
+ const err = new Error(`main discipline ${mainDiscipline} is not one of the chosen disciplines`);
186
+ err.code = 'invalid_main_discipline';
187
+ err.bad = [mainDiscipline];
188
+ throw err;
189
+ }
190
+ const setMain = mainDiscipline !== undefined;
164
191
  const { rows } = await pool.query(
165
- `UPDATE builders
166
- SET preferred_disciplines = $2
167
- WHERE id = $1
168
- RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, karma, created_at, rank, preferred_disciplines`,
169
- [builderId, normalized]
192
+ UPDATE_BUILDER_DISCIPLINES_SQL,
193
+ [builderId, normalized, setMain, setMain ? mainDiscipline : null]
194
+ );
195
+ return rows[0] ?? null;
196
+ }
197
+
198
+ // The two role facts the profile needs beside the public builder row (task
199
+ // 1004433): the preferred set and the chosen main role. Its own read, rather
200
+ // than a column added to getBuilderById, so the kernel's hot identity read is
201
+ // untouched by a profile feature.
202
+ async function getBuilderRoleChoice(builderId) {
203
+ const { rows } = await pool.query(
204
+ `SELECT preferred_disciplines, main_discipline FROM builders WHERE id = $1`,
205
+ [builderId]
170
206
  );
171
207
  return rows[0] ?? null;
172
208
  }
@@ -1065,6 +1101,8 @@ module.exports = {
1065
1101
  getBuilderByGithubId,
1066
1102
  hasInvitedAccessRequest,
1067
1103
  updateBuilderDisciplines,
1104
+ UPDATE_BUILDER_DISCIPLINES_SQL,
1105
+ getBuilderRoleChoice,
1068
1106
  RANKS,
1069
1107
  setBuilderRank,
1070
1108
  getLatestRankChangeForBuilder,
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/role-stats.js — EACH ROLE BY ITS OWN WORK: the reads behind the
4
+ // role-shaped profile (task 1004433 / WA6.RP03, goal 1000095, criterion
5
+ // wa6-roles-read-true-in-public). The owner-approved mock is
6
+ // docs/design/mocks/profile-roles/ (task 1004431); RP04 to RP07 draw from this.
7
+ //
8
+ // WHAT IT ANSWERS. For one builder: which of the three crafts they are shown in,
9
+ // which one leads, and each craft's own headline numbers.
10
+ //
11
+ // engineer `shipped`, works shipped (unchanged from today), and engineering credits
12
+ // artist pages approved, "N of M tweaked" per surface, credits from pages,
13
+ // this week (copy-desk's studio tally, the `copy-desk.artistStats`
14
+ // port)
15
+ // ideator thoughts filed, ignited, Full Ideas, ratified by the board, total
16
+ // size across every idea stream with the historical-estimate
17
+ // marker, and the share still alive (the sky's own reads, the
18
+ // `ideas.ideatorStats` port)
19
+ //
20
+ // Plus `credits_by_craft`, the ledger split of total_credits (economy's
21
+ // `reward.creditsByCraft`), which is what makes an artist or an ideator who has
22
+ // never shipped a task read as what they are.
23
+ //
24
+ // THE OWNER'S RULES (2026-09-30), and where each lives:
25
+ // • one person, one profile; the builder CHOOSES their main role
26
+ // (builders.main_discipline, core_268). Until they do, the main role is the
27
+ // craft they have earned most in, then their first preferred craft, then
28
+ // engineering, and `main_basis` says which of those it is, so a surface can
29
+ // tell a choice from a guess.
30
+ // • any OTHER role shows only once it has earned credits. `shown` is the main
31
+ // role first, then each craft with credits, in preferred order. A craft not
32
+ // in `shown` carries no stats block (null): its tab and chip do not exist.
33
+ // • decision (a): the identity band, the tally and "By the numbers" stay
34
+ // public, so everything here is counts and sums. No idea title, no page
35
+ // wording, no task list leaves this read; those stay behind a sign-in on the
36
+ // Activity routes. The answer is the same for every viewer.
37
+ //
38
+ // A port that is absent (a vanilla instance without the module) gives that
39
+ // block `null` fields rather than zeros: "could not ask" is not "nothing there".
40
+
41
+ const seams = require('../module-seams');
42
+
43
+ const CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
44
+ // A discipline as a craft, asked of economy (reward.craftForPreference), the
45
+ // module that decides which craft a credit belongs to. One table, so a builder's
46
+ // chips and their credits cannot disagree (`ui`, the design discipline, is the
47
+ // artist's craft there). With no economy module there are no credits to split
48
+ // either, and only the three craft names read as crafts.
49
+ function craftOf(discipline) {
50
+ const reward = seams.resolveOptional('reward');
51
+ if (reward && typeof reward.craftForPreference === 'function') return reward.craftForPreference(discipline);
52
+ return CRAFTS.includes(discipline) ? discipline : null;
53
+ }
54
+
55
+ function preferredCrafts(preferred) {
56
+ const out = [];
57
+ for (const d of Array.isArray(preferred) ? preferred : []) {
58
+ const c = craftOf(d);
59
+ if (c && !out.includes(c)) out.push(c);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // resolveMainRole — pure. The chosen craft, else the earned one, else the
65
+ // preferred one, else engineering (the platform's default craft).
66
+ function resolveMainRole({ mainDiscipline = null, preferred = [], byCraft = null } = {}) {
67
+ const chosen = craftOf(mainDiscipline);
68
+ if (chosen) return { main: chosen, basis: 'chosen' };
69
+ if (byCraft) {
70
+ let best = null;
71
+ for (const c of CRAFTS) {
72
+ const n = Number(byCraft[c]) || 0;
73
+ if (n > 0 && (!best || n > best.n)) best = { c, n };
74
+ }
75
+ if (best) return { main: best.c, basis: 'earned' };
76
+ }
77
+ const pref = preferredCrafts(preferred);
78
+ if (pref.length) return { main: pref[0], basis: 'preferred' };
79
+ return { main: 'engineer', basis: 'default' };
80
+ }
81
+
82
+ // shownCrafts — pure. Main first, then every craft with credits, in the
83
+ // builder's preferred order and then CRAFTS order.
84
+ function shownCrafts({ main, preferred = [], byCraft = null } = {}) {
85
+ const order = [...preferredCrafts(preferred), ...CRAFTS].filter((c, i, a) => a.indexOf(c) === i);
86
+ const out = [main];
87
+ for (const c of order) {
88
+ if (c === main) continue;
89
+ if (byCraft && Number(byCraft[c]) > 0) out.push(c);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // composeRoleStats — pure: the facts in, the profile's `roles` block out.
95
+ function composeRoleStats({ choice = null, byCraft = null, shippedCount = 0, artist = null, ideator = null } = {}) {
96
+ const preferred = (choice && choice.preferred_disciplines) || [];
97
+ const { main, basis } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
98
+ const shown = shownCrafts({ main, preferred, byCraft });
99
+ const credits = byCraft
100
+ ? { engineer: byCraft.engineer, artist: byCraft.artist, ideator: byCraft.ideator, other: byCraft.other, total: byCraft.total }
101
+ : null;
102
+ return {
103
+ main,
104
+ main_basis: basis,
105
+ shown,
106
+ preferred: preferredCrafts(preferred),
107
+ credits_by_craft: credits,
108
+ engineer: shown.includes('engineer')
109
+ ? { shipped: Number(shippedCount) || 0, credits: credits ? credits.engineer : null }
110
+ : null,
111
+ artist: shown.includes('artist') ? (artist || emptyArtist()) : null,
112
+ ideator: shown.includes('ideator') ? (ideator || emptyIdeator()) : null,
113
+ };
114
+ }
115
+
116
+ // The shapes a shown craft takes when its port could not be asked: every number
117
+ // null, so a renderer says "unknown", never "0".
118
+ function emptyArtist() {
119
+ return { pages_approved: null, pages: null, surfaces_touched: null, credits: null, this_week: null, surfaces: [], unavailable: true };
120
+ }
121
+ function emptyIdeator() {
122
+ return {
123
+ filed: null, ignited: null, full_ideas: null, ratified: null,
124
+ size: { total: null, paid: null, estimated: null, historical: null },
125
+ alive: { share: null, has_work: null, attributable: null },
126
+ unavailable: true,
127
+ };
128
+ }
129
+
130
+ // readRoleStats — the I/O half. The crafts are decided FIRST, from the ledger,
131
+ // so the heavier per-craft reads run only for a craft that will be shown.
132
+ async function readRoleStats(builderId, { shippedCount = 0, getRoleChoice } = {}) {
133
+ const reward = seams.resolveOptional('reward');
134
+ const [choice, byCraftMap] = await Promise.all([
135
+ getRoleChoice ? getRoleChoice(builderId) : null,
136
+ reward && typeof reward.creditsByCraft === 'function' ? reward.creditsByCraft([builderId]) : null,
137
+ ]);
138
+ const byCraft = byCraftMap ? (byCraftMap.get(String(builderId)) || null) : null;
139
+ const preferred = (choice && choice.preferred_disciplines) || [];
140
+ const { main } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
141
+ const shown = shownCrafts({ main, preferred, byCraft });
142
+
143
+ const artistPort = shown.includes('artist') ? seams.resolveOptional('copy-desk.artistStats') : null;
144
+ const ideatorPort = shown.includes('ideator') ? seams.resolveOptional('ideas.ideatorStats') : null;
145
+ const [artist, ideator] = await Promise.all([
146
+ typeof artistPort === 'function' ? artistPort(builderId) : null,
147
+ typeof ideatorPort === 'function' ? ideatorPort(builderId) : null,
148
+ ]);
149
+ return composeRoleStats({ choice, byCraft, shippedCount, artist, ideator });
150
+ }
151
+
152
+ // Only the reader is exported: the pure halves above are reached through it
153
+ // (tests/profile_role_stats.mjs drives them via the real route), and an export
154
+ // nothing imports is dead surface to the knip ratchet.
155
+ module.exports = { readRoleStats };
@@ -33,6 +33,7 @@ const { validateOrRespond, LIMITS, parseId } = require('./_helpers');
33
33
  // for the roster-visible checklist (read/shape) — never a direct require.
34
34
  const { pool } = require('../pool');
35
35
  const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
36
+ const { readRoleStats } = require('../role-stats');
36
37
  const seams = require('../../module-seams');
37
38
  const { branding } = require('../../branding');
38
39
  // The project's stale-claim timer (task 1003476, goal 1000072) — kernel config, so a
@@ -503,6 +504,11 @@ module.exports = function buildBuildersRouter() {
503
504
  // monthly_budget_usd / system_role) are NEVER spread into the
504
505
  // JSON. A leak-prevention unit test (tests/profile_route.mjs) asserts their
505
506
  // absence + the 404-on-unknown-id behaviour.
507
+ // `roles` (task 1004433) is the one block that is NOT a re-presentation of the
508
+ // leaderboard: per-craft counts and sums. The owner made them public on
509
+ // 2026-09-30 (RP01 decision (a): the tally and By the numbers stay public, the
510
+ // trail, the earnings and every Activity tab need a sign-in), so the block
511
+ // carries counts only — no idea title, no page wording, no task list.
506
512
  // THROTTLED since task 1002804. The global limiter meters WRITES only
507
513
  // (rateLimitMiddleware returns early for GET) and the public read limiter is
508
514
  // mounted under /public/*, which this is not — so this route answered at line
@@ -535,12 +541,17 @@ module.exports = function buildBuildersRouter() {
535
541
  // the same indistinguishable answer cross_project already gives.
536
542
  // Optional-chained so an older hub provider without the function degrades
537
543
  // to null rather than throwing across a version window.
538
- const [shippedCount, recentShips, achievements, crossProject, handle] = await Promise.all([
539
- lc().countShippedTasksForBuilder(targetId),
544
+ const shippedCount = await lc().countShippedTasksForBuilder(targetId);
545
+ // roles: each craft by its own work (task 1004433 / WA6.RP03) — which
546
+ // crafts show, which leads, and each one's headline numbers. Counts and
547
+ // sums only, the same for every viewer: the owner's decision (a) keeps the
548
+ // tally and By the numbers public and everything under them signed-in.
549
+ const [recentShips, achievements, crossProject, handle, roles] = await Promise.all([
540
550
  lc().recentShippedTasksByBuilder(targetId, 5),
541
551
  reward ? reward.getBuilderAchievements(targetId) : [],
542
552
  hub ? hub.getPublicProfileExtras(b.github_id) : null,
543
553
  hub && hub.getPublicHandle ? hub.getPublicHandle(b.github_id) : null,
554
+ readRoleStats(targetId, { shippedCount, getRoleChoice: db.getBuilderRoleChoice }),
544
555
  ]);
545
556
  // WHITELIST — only the leaderboard-equivalent public field set. Never spread
546
557
  // the raw row: it carries anthropic_email / anthropic_user_id /
@@ -573,6 +584,7 @@ module.exports = function buildBuildersRouter() {
573
584
  shipped_count: shippedCount,
574
585
  recent_ships: recentShips,
575
586
  achievements,
587
+ roles,
576
588
  cross_project: crossProject,
577
589
  handle: handle || null,
578
590
  });
@@ -582,6 +594,7 @@ module.exports = function buildBuildersRouter() {
582
594
  shipped_count: shippedCount,
583
595
  recent_ships: recentShips,
584
596
  achievements,
597
+ roles,
585
598
  });
586
599
  } catch (err) {
587
600
  log.error('[gds] GET /builders/:id/profile', err);
@@ -554,16 +554,24 @@ module.exports = function buildMeRouter() {
554
554
  // profile. 422 invalid_discipline if any element is out of range.
555
555
  // rank: any-builder — self-service profile edit; documented "no rank gate" in the
556
556
  // handler comment above (a builder owns their own preference).
557
+ // `main_discipline` (optional, task 1004433): the builder's own MAIN role, one
558
+ // of the crafts in the same body; null clears it, absent keeps it (see
559
+ // db.updateBuilderDisciplines). 422 invalid_main_discipline otherwise.
557
560
  router.patch('/me/disciplines', auth.requireBuilder, async (req, res) => {
558
561
  if (validateOrRespond(req, res, {
559
562
  preferred_disciplines: { required: true, type: 'array', maxItems: 16, itemsType: 'string' },
563
+ main_discipline: { type: 'string', maxLength: 32 },
560
564
  })) return;
561
565
  const input = req.body?.preferred_disciplines;
562
566
  if (!Array.isArray(input)) {
563
567
  return res.fail('preferred_disciplines must be an array', 400);
564
568
  }
569
+ const body = req.body || {};
570
+ const mainOpt = Object.prototype.hasOwnProperty.call(body, 'main_discipline')
571
+ ? { mainDiscipline: body.main_discipline === null ? null : String(body.main_discipline) }
572
+ : {};
565
573
  try {
566
- const builder = await db.updateBuilderDisciplines(req.builder.id, input);
574
+ const builder = await db.updateBuilderDisciplines(req.builder.id, input, mainOpt);
567
575
  // The onboarding state machine carved into modules/onboarding/ (BV1.R81 /
568
576
  // ADR 0093 §1): resolve the `onboarding` kernel port for the mark + echo —
569
577
  // never a direct require of a module. Default-on (present in prod); degrades
@@ -587,8 +595,8 @@ module.exports = function buildMeRouter() {
587
595
  const onboarding = onboardingPort ? onboardingPort.shapeForApi(state, { shipCount, discordLinked: !!discordLink, cliAuthed: !!cliAuthed }) : null;
588
596
  res.json({ builder, onboarding });
589
597
  } catch (err) {
590
- if (err.code === 'invalid_discipline') {
591
- return res.fail('invalid_discipline', 422, { bad: err.bad });
598
+ if (err.code === 'invalid_discipline' || err.code === 'invalid_main_discipline') {
599
+ return res.fail(err.code, 422, { bad: err.bad });
592
600
  }
593
601
  throw err;
594
602
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.58'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.59'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -35,7 +35,7 @@ api.pool = {
35
35
  async query(sql, params = []) {
36
36
  const q = flat(sql);
37
37
  seen.push({ q, params });
38
- if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
38
+ if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) {
39
39
  assert.match(q, /WHERE t\.source = 'page-tweak'/);
40
40
  // The fixture rows stand in for this SELECT, so every column they carry
41
41
  // must be one the SELECT really names: a renamed alias would otherwise
@@ -188,7 +188,7 @@ export function installPageLedger(api, { builders }) {
188
188
  return { rows: db.tasks.filter((t) => t.status === 'shipped' && Date.parse(t.shipped_at) > since && (t.touches || []).some((f) => p[0].includes(f))).map((t) => ({ id: t.id, title: t.title, shipped_at: t.shipped_at })) };
189
189
  }
190
190
  // The page reads (listPageTweakTasks), computed from the ledger's state.
191
- if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
191
+ if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) {
192
192
  assert.match(q, /WHERE t\.source = 'page-tweak'/);
193
193
  // The optional page scope (task 1004319): $1 is the round-ref prefix or null.
194
194
  assert.match(q, /left\(t\.source_ref, length\(\$1::text\)\) = \$1::text/);
@@ -0,0 +1,81 @@
1
+ // tests/main_discipline.mjs — the builder's own MAIN role (task 1004433 /
2
+ // WA6.RP03, core_268): what updateBuilderDisciplines sends for each of the
3
+ // three answers, and what it refuses before anything is written.
4
+ //
5
+ // The statement's own behaviour (keep, clear, set) runs against a real Postgres
6
+ // in tests/role_stats_db.mjs; this pins the half before it, DB-free: the
7
+ // "named or not" flag, the value, and the refusal of a main role that is not
8
+ // one of the builder's chosen crafts.
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ async function t(name, fn) {
18
+ try { await fn(); passed++; console.log(` ok ${name}`); }
19
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
20
+ }
21
+
22
+ const calls = [];
23
+ const poolPath = require.resolve('../src/bongos/pool.js');
24
+ require.cache[poolPath] = {
25
+ id: poolPath, filename: poolPath, loaded: true,
26
+ exports: {
27
+ pool: {
28
+ async query(sql, params) {
29
+ calls.push({ sql, params });
30
+ return { rows: [{ id: params[0], preferred_disciplines: params[1], main_discipline: params[2] ? params[3] : null }] };
31
+ },
32
+ },
33
+ },
34
+ };
35
+ const db = require('../src/bongos/db.js');
36
+ const { enabledDisciplines } = require('../src/modules.js');
37
+ const offered = enabledDisciplines();
38
+ const last = () => calls[calls.length - 1];
39
+
40
+ await t('the write goes through the one exported statement', async () => {
41
+ await db.updateBuilderDisciplines(7, ['engineer']);
42
+ assert.equal(last().sql, db.UPDATE_BUILDER_DISCIPLINES_SQL);
43
+ assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /main_discipline = CASE WHEN \$3::boolean THEN \$4::text/);
44
+ assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /WHEN main_discipline = ANY\(\$2::text\[\]\) THEN main_discipline\s+ELSE NULL END/);
45
+ });
46
+
47
+ await t('not mentioned: the flag is false, so the stored main role is left to the statement to keep or clear', async () => {
48
+ await db.updateBuilderDisciplines(7, ['engineer', 'ideator']);
49
+ assert.deepEqual(last().params.slice(2), [false, null]);
50
+ });
51
+
52
+ await t('named: the flag is true and the role travels', async () => {
53
+ await db.updateBuilderDisciplines(7, ['ideator', 'engineer'], { mainDiscipline: 'ideator' });
54
+ assert.deepEqual(last().params.slice(2), [true, 'ideator']);
55
+ assert.deepEqual(last().params[1], offered.filter((d) => ['ideator', 'engineer'].includes(d)),
56
+ 'the set is still stored in canonical order: the main role is what carries the choice');
57
+ });
58
+
59
+ await t('null clears it on purpose', async () => {
60
+ await db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: null });
61
+ assert.deepEqual(last().params.slice(2), [true, null]);
62
+ });
63
+
64
+ await t('a main role that is not one of the chosen crafts is refused, and nothing is written', async () => {
65
+ const before = calls.length;
66
+ await assert.rejects(() => db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: 'ideator' }),
67
+ (err) => err.code === 'invalid_main_discipline' && err.bad[0] === 'ideator');
68
+ assert.equal(calls.length, before);
69
+ });
70
+
71
+ await t('the PATCH route hands main_discipline through only when the body names it', async () => {
72
+ const fs = require('node:fs');
73
+ const src = fs.readFileSync(require.resolve('../src/bongos/routes/me.js'), 'utf8');
74
+ const route = src.slice(src.indexOf("router.patch('/me/disciplines'"), src.indexOf("router.patch('/me/disciplines'") + 2500);
75
+ assert.match(route, /hasOwnProperty\.call\(body, 'main_discipline'\)/);
76
+ assert.match(route, /db\.updateBuilderDisciplines\(req\.builder\.id, input, mainOpt\)/);
77
+ assert.match(route, /invalid_main_discipline/);
78
+ });
79
+
80
+ console.log(`\n${passed} passed, ${failed} failed`);
81
+ if (failed) process.exit(1);