@bongos/core 1.19.363 → 1.19.365

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,222 @@
1
+ // tests/government_board_summons.mjs — the summons: what is waiting on THIS
2
+ // member's vote (task 1003271).
3
+ //
4
+ // THE BUG THIS PINS. Nine sittings sat open for two days with zero votes. The
5
+ // board was never deadlocked — three of four archons could each have carried any
6
+ // item with one click — they were simply never told, because the Discord
7
+ // announcer was the only summons and it is best-effort by construction (an
8
+ // unconfigured webhook, an unlinked member and a failed POST all end in a
9
+ // swallowed log line). The durable summons is now the builder-needs roster.
10
+ //
11
+ // The rules pinned here:
12
+ // 1. THE COUNT MATCHES WHAT THE VOTE ROUTE WOULD ACCEPT — a member, not the
13
+ // author, not already cast. Anything the route would refuse must not be
14
+ // counted, or the roster promises a ballot the route then denies.
15
+ // 2. THE AUTHOR IS EXCLUDED, and that is the whole live symptom: the owner
16
+ // authored all nine, so their own count is zero while a peer's is nine.
17
+ // 3. MEMBERSHIP IS PER-ITEM, off the item's OWN snapshotted constitution —
18
+ // never the live config — and resolved ONCE PER DISTINCT PREDICATE.
19
+ // 4. FAIL-CLOSED AND FAIL-OPEN IN THE RIGHT DIRECTIONS: an unreadable
20
+ // predicate summons nobody; a DB error yields a zero count, never a throw
21
+ // (this runs on the /me session hot path and must not break a profile).
22
+ //
23
+ // DB-free (db monkeypatched — the sibling board tests' idiom).
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { createRequire } from 'node:module';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const GOV = path.join(ROOT, 'modules', 'government');
33
+ const board = require(path.join(GOV, 'board.js'));
34
+ const db = require(path.join(GOV, 'db.js'));
35
+ const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
36
+
37
+ async function withDb(stubs, fn) {
38
+ const originals = {};
39
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
40
+ try { return await fn(); }
41
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
42
+ }
43
+
44
+ const ARCHON = { membership: 'rank:archon', pass_rule: 'first_ratifier' };
45
+
46
+ // One open item. author_id/already_voted are what the SQL hands back.
47
+ const item = (id, { author = null, voted = false, constitution = ARCHON } = {}) => ({
48
+ id: String(id),
49
+ subject_type: 'full_idea',
50
+ subject_id: String(1000000 + Number(id)),
51
+ constitution,
52
+ opened_at: `2026-08-2${(Number(id) % 9) + 1}T00:00:00Z`,
53
+ closes_at: null,
54
+ author_id: author == null ? null : String(author),
55
+ already_voted: voted,
56
+ });
57
+
58
+ // ── rule 2: the live symptom — author sees nothing, a peer sees all nine ─────
59
+
60
+ test('the author of every open item is summoned to none of them', async () => {
61
+ // The exact live shape: 9 items, all authored by builder 1000025, membership
62
+ // rank:archon, and 1000025 IS an archon. The vote route would refuse each one
63
+ // (anti_self_vote), so the roster must not claim any are waiting on them.
64
+ const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
65
+ await withDb({
66
+ openBoardItemsAwaitingVoter: async () => items,
67
+ builderHoldsRankActive: async () => true, // they really are an archon
68
+ }, async () => {
69
+ const out = await board.pendingVotesFor(1000025);
70
+ assert.equal(out.count, 0, 'the author must be summoned to zero of their own sittings');
71
+ assert.equal(out.first_item_id, null);
72
+ });
73
+ });
74
+
75
+ test('a peer archon who authored none of them is summoned to all nine', async () => {
76
+ const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
77
+ await withDb({
78
+ openBoardItemsAwaitingVoter: async () => items,
79
+ builderHoldsRankActive: async () => true,
80
+ }, async () => {
81
+ const out = await board.pendingVotesFor(1000094); // scootermack, not the author
82
+ assert.equal(out.count, 9);
83
+ // Oldest first — the sitting that has waited longest is the one to answer.
84
+ assert.equal(out.first_item_id, '1');
85
+ });
86
+ });
87
+
88
+ // ── rule 1: the count matches what the route would accept ────────────────────
89
+
90
+ test('an already-cast vote is not counted again', async () => {
91
+ await withDb({
92
+ openBoardItemsAwaitingVoter: async () => [
93
+ item(1, { author: 7, voted: true }),
94
+ item(2, { author: 7, voted: false }),
95
+ ],
96
+ builderHoldsRankActive: async () => true,
97
+ }, async () => {
98
+ const out = await board.pendingVotesFor(1);
99
+ assert.equal(out.count, 1, 'one vote each — a cast item is settled for this voter');
100
+ assert.equal(out.first_item_id, '2');
101
+ });
102
+ });
103
+
104
+ test('a non-member is summoned to nothing, however many items are open', async () => {
105
+ await withDb({
106
+ openBoardItemsAwaitingVoter: async () => [item(1, { author: 7 }), item(2, { author: 7 })],
107
+ builderHoldsRankActive: async () => false, // not an archon
108
+ }, async () => {
109
+ const out = await board.pendingVotesFor(999);
110
+ assert.equal(out.count, 0);
111
+ });
112
+ });
113
+
114
+ test('an item with no author at all stays votable (nobody to disqualify)', async () => {
115
+ await withDb({
116
+ openBoardItemsAwaitingVoter: async () => [item(1, { author: null })],
117
+ builderHoldsRankActive: async () => true,
118
+ }, async () => {
119
+ assert.equal((await board.pendingVotesFor(1000025)).count, 1);
120
+ });
121
+ });
122
+
123
+ // ── rule 3: membership is per-item, and resolved once per predicate ──────────
124
+
125
+ test('membership comes from the ITEM snapshot, not one global answer', async () => {
126
+ // Two items under DIFFERENT constitutions: this builder sits on one board only.
127
+ const metic = { membership: 'rank:metic+', pass_rule: 'first_ratifier' };
128
+ await withDb({
129
+ openBoardItemsAwaitingVoter: async () => [
130
+ item(1, { author: 7, constitution: ARCHON }),
131
+ item(2, { author: 7, constitution: metic }),
132
+ ],
133
+ builderHoldsRankActive: async (_id, rankKeys) => rankKeys.includes('metic'),
134
+ }, async () => {
135
+ const out = await board.pendingVotesFor(1000027);
136
+ assert.equal(out.count, 1, 'only the sitting whose snapshot enfranchises them');
137
+ assert.equal(out.first_item_id, '2');
138
+ });
139
+ });
140
+
141
+ test('one membership resolve per DISTINCT predicate, not one per item', async () => {
142
+ let calls = 0;
143
+ const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 7 }));
144
+ await withDb({
145
+ openBoardItemsAwaitingVoter: async () => items,
146
+ builderHoldsRankActive: async () => { calls++; return true; },
147
+ }, async () => {
148
+ const out = await board.pendingVotesFor(5);
149
+ assert.equal(out.count, 9);
150
+ assert.equal(calls, 1, 'nine items under one constitution cost ONE resolve');
151
+ });
152
+ });
153
+
154
+ // ── rule 4: the two failure directions ──────────────────────────────────────
155
+
156
+ test('an unreadable membership predicate summons nobody (fail-closed)', async () => {
157
+ await withDb({
158
+ openBoardItemsAwaitingVoter: async () => [
159
+ item(1, { author: 7, constitution: { membership: 'not a predicate' } }),
160
+ ],
161
+ // Would say yes if it were ever asked — the parse must refuse first.
162
+ builderHoldsRankActive: async () => true,
163
+ }, async () => {
164
+ assert.equal((await board.pendingVotesFor(1)).count, 0);
165
+ });
166
+ });
167
+
168
+ test('a DB error yields a zero count, never a throw (the /me hot path)', async () => {
169
+ await withDb({
170
+ openBoardItemsAwaitingVoter: async () => { throw new Error('boom'); },
171
+ }, async () => {
172
+ const out = await board.pendingVotesFor(1);
173
+ assert.equal(out.count, 0);
174
+ assert.equal(out.first_item_id, null);
175
+ });
176
+ });
177
+
178
+ test('a missing builder id is answered without touching the database', async () => {
179
+ await withDb({
180
+ openBoardItemsAwaitingVoter: async () => { throw new Error('must not be called'); },
181
+ }, async () => {
182
+ for (const id of [null, undefined, '']) {
183
+ assert.equal((await board.pendingVotesFor(id)).count, 0);
184
+ }
185
+ });
186
+ });
187
+
188
+ // ── the need the count feeds ─────────────────────────────────────────────────
189
+
190
+ test('zero pending votes produces NO need row', () => {
191
+ // An empty state is not an input the system needs from you — the sibling
192
+ // collab needs take the same line.
193
+ assert.equal(needs.boardVotesNeed({}), null);
194
+ assert.equal(needs.boardVotesNeed({ pendingBoardVotes: { count: 0 } }), null);
195
+ assert.equal(needs.boardVotesNeed({ pendingBoardVotes: null }), null);
196
+ });
197
+
198
+ test('pending votes are action_needed and deep-link the oldest sitting', () => {
199
+ const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
200
+ assert.equal(n.id, 'board_votes');
201
+ assert.equal(n.state, 'action_needed');
202
+ assert.equal(n.action.href, '/government?item=1#board-room');
203
+ // The hash is the TAB (makeTabs owns it); the item is a query param.
204
+ assert.match(n.action.href, /#board-room$/);
205
+ });
206
+
207
+ test('a count with no item id still links the room', () => {
208
+ const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
209
+ assert.equal(n.action.href, '/government#board-room');
210
+ });
211
+
212
+ test('the need reaches computeNeeds as an action_needed item', () => {
213
+ const out = needs.computeNeeds({
214
+ rank: 'archon', ownKeySet: true,
215
+ pendingBoardVotes: { count: 3, first_item_id: '4' },
216
+ });
217
+ const row = out.items.find((i) => i.id === 'board_votes');
218
+ assert.ok(row, 'the registry must carry the new need');
219
+ assert.equal(out.has_action_needed, true);
220
+ // action_needed sorts first, so a renderer taking "the first one" gets it.
221
+ assert.equal(out.items[0].id, 'board_votes');
222
+ });
@@ -132,15 +132,46 @@ test('the demotion between open and vote refuses the vote — live re-check, unc
132
132
  });
133
133
  });
134
134
 
135
- test('anti-self: the author cannot vote on their own item — number/string ids compare equal', async () => {
135
+ test('an author MAY cast on their own item, earns no karma for it — number/string ids compare equal', async () => {
136
+ // Task 1003273 replaced the flat refusal: an author may vote, and may never
137
+ // decide alone (enforced by the close trigger and the tally, not by refusing
138
+ // the cast — see government_board_majority.mjs). The id-shape detail this test
139
+ // has always guarded still matters: Postgres hands back a NUMBER author id
140
+ // while the voter carries a STRING, and the self-cast comparison must see
141
+ // through that, or an author would silently be paid karma on their own item.
142
+ let karmaPaid = 0;
136
143
  await withDb({
137
144
  getBoardItemForVote: async () => openItem(),
138
145
  builderHoldsRankActive: async () => true,
139
- getBoardItemAuthor: async () => 42, // Postgres hands back a number; the voter carries a string
140
- insertBoardVote: async () => { throw new Error('must not be reached'); },
146
+ getBoardItemAuthor: async () => 42, // number, deliberately
147
+ insertBoardVote: async (args) => ({ id: '9', voter_id: args.voterId, direction: args.direction }),
148
+ insertVoteKarma: async () => { karmaPaid += 1; },
141
149
  }, async () => {
142
150
  const out = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
143
- assert.deepEqual(out, { ok: false, reason: 'anti_self_vote' });
151
+ assert.equal(out.ok, true);
152
+ assert.equal(out.self_cast, true, '42 and "42" are the same author');
153
+ assert.equal(out.karma_delta, 0);
154
+ assert.equal(karmaPaid, 0, 'a self-cast writes no karma row');
155
+ });
156
+ });
157
+
158
+ test('a NON-author on the same item is paid the vote karma as before', async () => {
159
+ // The contrast, so the no-karma rule above cannot silently become "no karma
160
+ // for anyone" — which would quietly delete the incentive to read other
161
+ // people's ideas.
162
+ let karmaPaid = 0;
163
+ await withDb({
164
+ getBoardItemForVote: async () => openItem(),
165
+ builderHoldsRankActive: async () => true,
166
+ getBoardItemAuthor: async () => 42,
167
+ insertBoardVote: async (args) => ({ id: '9', voter_id: args.voterId, direction: args.direction }),
168
+ insertVoteKarma: async () => { karmaPaid += 1; },
169
+ }, async () => {
170
+ const out = await castVote({ itemId: 11, voterId: '77', direction: 'yes' });
171
+ assert.equal(out.ok, true);
172
+ assert.equal(out.self_cast, false);
173
+ assert.equal(out.karma_delta, VOTE_KARMA_DELTA);
174
+ assert.equal(karmaPaid, 1);
144
175
  });
145
176
  });
146
177
 
@@ -47,11 +47,26 @@ test('an objection needs the reason AND exactly one section; a yes sends neither
47
47
  assert.match(cast, /item_closed/, 'a cast that CLOSED the sitting says so (first_ratifier / full turnout)');
48
48
  });
49
49
 
50
- test('the author of a full_idea sees the anti-self note instead of a form — amendments do not', () => {
50
+ test('the author of a full_idea gets a REAL ballot plus the limit in words (task 1003273)', () => {
51
+ // This test used to assert the opposite — that an author saw a note INSTEAD of
52
+ // a form. That was the shape of the live bug: every open item was the reader's
53
+ // own, so the room offered nine sittings and no way to vote on any of them. The
54
+ // owner's rule is now "may vote, may not decide alone", so the form must be
55
+ // there and the limit must be stated rather than enforced by a missing button.
51
56
  const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
52
57
  assert.match(form, /subject_type === 'full_idea'[^]*author_id.*viewerId/,
53
- 'the grey-out is SUBJECT-TYPED, mirroring castVote\'s carve-out — an amendment proposer may vote (§7)');
54
- assert.match(form, /does not vote on it/, 'the note says why, not just no');
58
+ 'still SUBJECT-TYPED — an amendment proposer is not held to the author rule (§7)');
59
+ // The note is now additive: it rides ALONGSIDE the buttons, not in place of them.
60
+ assert.match(form, /ownNote/, 'the own-idea note is composed into the form');
61
+ assert.match(form, /can\u2019t be decided by your vote alone|decided by your vote alone/,
62
+ 'the note states the real limit');
63
+ assert.match(form, /earns no karma/, 'and that a self-cast is unpaid');
64
+ // The single most important structural assertion: there is exactly ONE return
65
+ // of the ballot, so no branch can silently withhold it from the author again.
66
+ assert.equal((form.match(/data-vote="yes"/g) || []).length, 1,
67
+ 'one ballot, reachable by author and peer alike');
68
+ assert.ok(!/return `<p class="gov-grid-note">This is your idea/.test(form),
69
+ 'the old form-suppressing early return is gone, not merely bypassed');
55
70
  assert.match(form, /if \(mine\) return ''/, 'one vote per member — a cast voter gets no second form');
56
71
  assert.match(form, /if \(item\.closed_at\) return ''/, 'closed sittings take no votes');
57
72
  });
@@ -265,7 +265,7 @@ test('a malformed constitution can never widen the franchise or lower the bar',
265
265
  { membership: '' },
266
266
  { membership: { rank: 'archon' } },
267
267
  { pass_rule: 'unanimous' },
268
- { pass_rule: 'majority' },
268
+ { pass_rule: 'unanimous' },
269
269
  { pass_rule: null },
270
270
  { membership: 'rank:xenos; DROP TABLE government_ranks' },
271
271
  ];
@@ -325,7 +325,14 @@ test('the resolved constitution is frozen, like the rest of the config contract'
325
325
  });
326
326
 
327
327
  test('PASS_RULES is the closed set the rest of the goal codes against', () => {
328
- assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier']);
328
+ // `majority` joined the set in task 1003273 (owner decision 2026-08-25). The
329
+ // set stays CLOSED and asserted whole on purpose: board.js branches on these
330
+ // strings, so a rule added to config without a tally to match would be a
331
+ // constitution a project could adopt and then never resolve.
332
+ assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier', 'majority']);
329
333
  assert.ok(Object.isFrozen(PASS_RULES));
330
334
  assert.ok(PASS_RULES.includes(BOARD_DEFAULTS.pass_rule), 'the default must be a member of the set');
335
+ // The fail-closed default is UNCHANGED by the addition — adding a stricter rule
336
+ // must not move what a malformed constitution falls back to.
337
+ assert.equal(BOARD_DEFAULTS.pass_rule, 'first_ratifier');
331
338
  });