@bongos/core 1.19.805 → 1.19.807

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,307 @@
1
+ // tests/goal_invite_consent.mjs — the goals page INVITES; it does not force-add
2
+ // behind the word "Invited." (task 1002886, goal 1000095).
3
+ //
4
+ // WHY THIS FILE EXISTS. The '+ Invite' control posted to POST /goals/:id/members
5
+ // — the immediate force-add — and then toasted 'Invited.' The person was already
6
+ // in the goal and had never been asked. Meanwhile ADR 0112's consented flow
7
+ // (POST /goals/:id/invitations -> a pending row -> the invitee accepts) had its
8
+ // RECEIVING half fully wired in goal-inbox.js and no sender anywhere in the hall,
9
+ // so nothing in the product ever created an invitation.
10
+ //
11
+ // Nothing in tests/ touched the invite box before this file — which is how a
12
+ // control could claim one thing and do the opposite for as long as it did. The
13
+ // lesson generalises past this button: assert the REQUEST a control issues and
14
+ // the SENTENCE it then shows, because those two drifting apart is invisible to
15
+ // any test that only checks the control renders.
16
+ //
17
+ // It runs the REAL goals-render.js (plus the real goals-lib, task-lib and kit)
18
+ // in a vm against a stub DOM and a recording postJSON, and drives renderDetail —
19
+ // the actual entry point, not the private renderer — so what is asserted is the
20
+ // call the page really makes.
21
+ //
22
+ // Run: node tests/goal_invite_consent.mjs
23
+ import { strict as assert } from 'node:assert';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import vm from 'node:vm';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
32
+ const P = 'modules/hall-ui/public/';
33
+ const RENDER_JS = read(`${P}goals-render.js`);
34
+
35
+ const { test, summary } = makeRunner();
36
+ const tick = () => new Promise((r) => setTimeout(r, 0));
37
+
38
+ const SELF = 7001; // the viewer: the goal's creator, so canManage is true
39
+ const MEMBER_ID = 7002; // already in the goal
40
+ const PENDING_ID = 7003; // holds a pending invitation the goal sent
41
+ const FREE_ID = 7004; // neither — the only genuinely invitable builder
42
+
43
+ function makeEl(id) {
44
+ const el = {
45
+ id,
46
+ innerHTML: '', textContent: '', value: '', className: '',
47
+ hidden: false, disabled: false, checked: false,
48
+ options: [], style: {}, dataset: {},
49
+ children: [],
50
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
51
+ appendChild(c) { el.children.push(c); return c; },
52
+ setAttribute() {}, removeAttribute() {}, focus() {}, scrollIntoView() {},
53
+ _on: new Map(),
54
+ addEventListener(ev, fn) {
55
+ if (!el._on.has(ev)) el._on.set(ev, []);
56
+ el._on.get(ev).push(fn);
57
+ },
58
+ fire(ev, arg) {
59
+ for (const fn of el._on.get(ev) || []) fn(arg || { preventDefault() {} });
60
+ },
61
+ querySelector() { return null; },
62
+ querySelectorAll() { return []; },
63
+ };
64
+ return el;
65
+ }
66
+
67
+ function harness({ pendingRequests = [] } = {}) {
68
+ const els = new Map();
69
+ // The box is re-rendered by replacing innerHTML, and the stub DOM does not
70
+ // parse HTML — so the controls the page writes into #goal-invite are modelled
71
+ // by their ids, which is exactly how the page reaches them ($('#id')).
72
+ // Ids the invite box writes INTO itself. Listed rather than inferred because
73
+ // the stub does not parse HTML — this is the seam where it has to imitate the
74
+ // one thing real innerHTML does that matters here: destroy what was there.
75
+ const INVITE_CHILD_IDS = [
76
+ 'goal-invite-open', 'goal-invite-form', 'goal-invite-builder', 'goal-invite-note',
77
+ 'goal-invite-cancel', 'goal-direct-open', 'goal-direct-form', 'goal-direct-builder',
78
+ 'goal-direct-kind', 'goal-direct-cancel',
79
+ ];
80
+ const getEl = (id) => {
81
+ const key = String(id).replace(/^#/, '');
82
+ if (!els.has(key)) {
83
+ const el = makeEl(key);
84
+ if (key === 'goal-invite') {
85
+ // THE BUG THIS MODELS IS REAL: renderInviteBox rebuilds the box on every
86
+ // call, and a stub that kept the old nodes alive would keep their click
87
+ // handlers too — so a single click would re-enter the renderer once per
88
+ // previous render. That is a defect in the STUB, not the page, but only
89
+ // because the page really does replace these nodes. Keep them in step.
90
+ let html = '';
91
+ Object.defineProperty(el, 'innerHTML', {
92
+ get: () => html,
93
+ set: (v) => { html = String(v); for (const cid of INVITE_CHILD_IDS) els.delete(cid); },
94
+ });
95
+ }
96
+ els.set(key, el);
97
+ }
98
+ return els.get(key);
99
+ };
100
+ const documentObj = {
101
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
102
+ body: makeEl('body'),
103
+ getElementById: getEl,
104
+ querySelector: (sel) => getEl(sel),
105
+ querySelectorAll: () => [],
106
+ createElement: () => makeEl(),
107
+ addEventListener() {},
108
+ };
109
+
110
+ const posts = [];
111
+ const toasts = [];
112
+ const state = {
113
+ current: 1,
114
+ view: 'detail',
115
+ details: new Map(),
116
+ directory: [
117
+ { id: MEMBER_ID, display_name: 'Ada Member', github_login: 'ada' },
118
+ { id: PENDING_ID, display_name: 'Ben Pending', github_login: 'ben' },
119
+ { id: FREE_ID, display_name: 'Cleo Free', github_login: 'cleo' },
120
+ ],
121
+ pendingRequests,
122
+ myPendingRequest: null,
123
+ inviteOpen: false,
124
+ addDirectOpen: false,
125
+ // The rest of the page state renderDetail walks through on its way to the
126
+ // invite box. Shapes copied from goals-page.js's initial state — the
127
+ // renderers read several of these unguarded, so a partial stub throws
128
+ // before execution ever reaches what is under test here.
129
+ workSort: { todo: { by: null, dir: 'desc' }, done: { by: null, dir: 'desc' } },
130
+ work: { todo: [], done: [] },
131
+ workLoaded: false,
132
+ claimsByTask: new Map(),
133
+ conflictPairs: null,
134
+ conflictSource: null,
135
+ rollups: new Map(),
136
+ activity: new Map(),
137
+ };
138
+
139
+ const windowObj = {
140
+ location: { assign() {}, href: '/goals' },
141
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
142
+ scrollTo() {}, addEventListener() {},
143
+ matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
144
+ OTB: {
145
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
146
+ $: (sel) => documentObj.querySelector(sel),
147
+ fmtNum: (n) => String(n ?? ''),
148
+ fmtInt: (n) => String(n ?? ''),
149
+ fmtDate: (d) => String(d ?? '').slice(0, 10),
150
+ toast: (m) => toasts.push(String(m)),
151
+ rankLabel: (r) => String(r ?? ''),
152
+ categoryChip: () => '',
153
+ num: (n) => Number(n) || 0,
154
+ },
155
+ };
156
+
157
+ const sandbox = {
158
+ window: windowObj, document: documentObj, location: windowObj.location,
159
+ console: { error() {}, warn() {}, log() {} },
160
+ setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON, Set, Map,
161
+ fetch: () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }),
162
+ };
163
+ sandbox.globalThis = sandbox;
164
+ vm.createContext(sandbox);
165
+ for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goals-render.js']) {
166
+ vm.runInContext(read(P + f), sandbox, { filename: f });
167
+ }
168
+
169
+ const goal = {
170
+ id: 1, version_id: 'BONGOS-V2', status: 'open', visibility: 'public',
171
+ created_by: SELF, title: 'A goal', scope_modules: [], accepting_requests: true,
172
+ };
173
+ const members = [{ builder_id: MEMBER_ID, display_name: 'Ada Member', membership_kind: 'member' }];
174
+ const detail = { goal, members, criteria: [], satisfied: [], tasks: [] };
175
+ state.details.set('1', detail);
176
+
177
+ const api = sandbox.window.OTBGoalsRender({
178
+ API: '/api/bongos',
179
+ curLabel: () => 'Credits',
180
+ // URL-aware, because the renderers walk several feeds on the way to the
181
+ // invite box and a one-shape-fits-all reply feeds the wrong array into the
182
+ // wrong pager.
183
+ getJSON: async (url) => {
184
+ if (/\/goals\/\d+\/requests$/.test(url)) return { requests: pendingRequests };
185
+ if (/\/goals\/inbox$/.test(url)) return { myPendingRequests: [] };
186
+ if (/\/builders\/directory/.test(url)) return { builders: state.directory };
187
+ if (/\/tasks/.test(url)) return { tasks: [] };
188
+ if (/\/claims/.test(url)) return { claims: [] };
189
+ return {};
190
+ },
191
+ patchJSON: async () => ({}),
192
+ postJSON: async (url, body) => { posts.push({ url, body }); return { ok: true }; },
193
+ sameId: (a, b) => String(a ?? '') === String(b ?? ''),
194
+ selfId: () => SELF,
195
+ state,
196
+ get workTabs() { return { activate() {} }; },
197
+ });
198
+
199
+ return { api, state, posts, toasts, getEl, els };
200
+ }
201
+
202
+ // Open the invite box the way a user does: render the detail, then click
203
+ // "+ Invite". Returns the harness with the form on screen.
204
+ async function openInviteForm(h) {
205
+ h.api.renderDetail(1);
206
+ await tick();
207
+ h.getEl('goal-invite-open').fire('click');
208
+ await tick(); await tick();
209
+ return h;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+
214
+ await test('the source no longer names the force-add as an "invite"', () => {
215
+ assert.ok(!/async function inviteMember\b/.test(RENDER_JS),
216
+ 'inviteMember() was the force-add wearing the word invite — it is split in two');
217
+ assert.match(RENDER_JS, /async function sendInvitation\(/, 'the consented sender exists');
218
+ assert.match(RENDER_JS, /async function addMemberDirectly\(/, 'the force-add is named for what it does');
219
+ assert.ok(!/toast\('Invited\.'\)/.test(RENDER_JS), "the 'Invited.' toast on a force-add is gone");
220
+ });
221
+
222
+ await test('the invite form posts to /invitations and never claims they joined', async () => {
223
+ const h = await openInviteForm(harness());
224
+ h.getEl('goal-invite-builder').value = String(FREE_ID);
225
+ h.getEl('goal-invite-note').value = ' we could use your eye on this ';
226
+ h.getEl('goal-invite-form').fire('submit');
227
+ await tick(); await tick();
228
+
229
+ assert.equal(h.posts.length, 1, 'exactly one write');
230
+ const { url, body } = h.posts[0];
231
+ assert.match(url, /\/goals\/1\/invitations$/, 'the CONSENTED endpoint, not /members');
232
+ assert.ok(!/\/members$/.test(url), 'the force-add endpoint is not what Invite calls');
233
+ assert.equal(body.builder_id, FREE_ID);
234
+ assert.equal(body.note, 'we could use your eye on this', 'the note is sent, trimmed');
235
+ assert.ok(!('membership_kind' in body),
236
+ 'the invitations route carries no membership_kind — sending one would be a choice dropped in silence');
237
+
238
+ const said = h.toasts.join(' ');
239
+ assert.match(said, /accept/i, 'the toast turns on their acceptance');
240
+ assert.ok(!/^Invited\.$/.test(h.toasts[0]), 'the old lie is not relocated');
241
+ assert.ok(!/\b(added|joined|is now a member)\b/i.test(said),
242
+ `nobody joined anything yet — got: ${JSON.stringify(h.toasts)}`);
243
+ });
244
+
245
+ await test('an empty note is omitted rather than sent as an empty string', async () => {
246
+ const h = await openInviteForm(harness());
247
+ h.getEl('goal-invite-builder').value = String(FREE_ID);
248
+ h.getEl('goal-invite-note').value = ' ';
249
+ h.getEl('goal-invite-form').fire('submit');
250
+ await tick(); await tick();
251
+ assert.ok(!('note' in h.posts[0].body), 'a blank note is absent, not ""');
252
+ });
253
+
254
+ await test('the invite form offers no membership-kind picker', async () => {
255
+ const h = await openInviteForm(harness());
256
+ const html = h.getEl('goal-invite').innerHTML;
257
+ assert.ok(!/id="goal-invite-kind"/.test(html),
258
+ 'the picker cannot be honoured by an invitation — goal_membership_requests has no such column');
259
+ assert.match(html, /join as a member/i, 'and the form says what they will join as instead');
260
+ });
261
+
262
+ await test('the dropdown excludes members AND anyone already pending', async () => {
263
+ const h = await openInviteForm(harness({
264
+ pendingRequests: [{ id: 55, kind: 'invite', builder_id: PENDING_ID, display_name: 'Ben Pending' }],
265
+ }));
266
+ const html = h.getEl('goal-invite').innerHTML;
267
+ assert.ok(html.includes(`value="${FREE_ID}"`), 'the invitable builder is offered');
268
+ assert.ok(!html.includes(`value="${MEMBER_ID}"`), 'a member would 409 already_a_member');
269
+ // The one that matters: a pending invitation is INVISIBLE in the member list,
270
+ // so without this filter the user picks an ordinary-looking name and is handed
271
+ // an error they had no way to predict.
272
+ assert.ok(!html.includes(`value="${PENDING_ID}"`), 'a pending invitee would 409 invite_pending');
273
+ });
274
+
275
+ await test('a pending JOIN-REQUEST is excluded too, not just an invitation', async () => {
276
+ const h = await openInviteForm(harness({
277
+ pendingRequests: [{ id: 56, kind: 'request', builder_id: PENDING_ID }],
278
+ }));
279
+ const html = h.getEl('goal-invite').innerHTML;
280
+ assert.ok(!html.includes(`value="${PENDING_ID}"`),
281
+ 'the server refuses on EITHER kind, so the filter may not look only at invites');
282
+ });
283
+
284
+ await test('the force-add survives, one click away, and says nobody was asked', async () => {
285
+ const h = await openInviteForm(harness());
286
+ const collapsed = h.getEl('goal-invite').innerHTML;
287
+ assert.match(collapsed, /without asking/i, 'the path is discoverable, not removed');
288
+ assert.ok(!/id="goal-direct-form"/.test(collapsed), 'but it is not the default control');
289
+
290
+ h.getEl('goal-direct-open').fire('click');
291
+ await tick();
292
+ const open = h.getEl('goal-invite').innerHTML;
293
+ assert.match(open, /id="goal-direct-kind"/, 'the membership kind IS honoured on this path, so it is offered here');
294
+
295
+ h.getEl('goal-direct-builder').value = String(FREE_ID);
296
+ h.getEl('goal-direct-kind').value = 'lead';
297
+ h.getEl('goal-direct-form').fire('submit');
298
+ await tick(); await tick();
299
+
300
+ assert.equal(h.posts.length, 1);
301
+ assert.match(h.posts[0].url, /\/goals\/1\/members$/, 'the force-add endpoint');
302
+ assert.equal(h.posts[0].body.membership_kind, 'lead', 'and the chosen kind travels');
303
+ const said = h.toasts.join(' ');
304
+ assert.match(said, /without being asked/i, 'the consequence is stated, not hidden behind "Invited."');
305
+ });
306
+
307
+ summary();
@@ -50,13 +50,73 @@ test('cronCadence refuses what its vocabulary cannot describe, rather than guess
50
50
  // Unclassifiable must mean "never matches a declared cadence" — the safe
51
51
  // direction. A clever cron that silently counted as `weekly` would reopen
52
52
  // exactly the drift this check exists to catch.
53
- for (const expr of ['*/5 * * * *', '0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
53
+ for (const expr of ['0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
54
54
  assert.equal(rs.cronCadence(expr), null, `${expr} should not classify`);
55
55
  }
56
56
  // A day-of-month AND day-of-week together is an OR in cron — neither period.
57
57
  assert.equal(rs.cronCadence('17 6 3 * 1'), null);
58
58
  });
59
59
 
60
+ test('cronCadence classifies a uniform sub-hourly step (task 1003674)', () => {
61
+ // The vocabulary used to stop at `hourly`, so a routine meant to fire every
62
+ // 15 minutes had no way to declare it: the cron that realizes it read as
63
+ // unclassifiable and hard-failed, leaving "declare nothing, stay dormant" as
64
+ // the only green option — the state this check exists to refuse.
65
+ assert.equal(rs.cronCadence('*/15 * * * *'), '15min');
66
+ assert.equal(rs.cronCadence('*/5 * * * *'), '5min');
67
+ assert.equal(rs.cronCadence('*/30 * * * *'), '30min');
68
+ assert.equal(rs.cronCadence('0/15 * * * *'), '15min', 'the 0/N spelling is the same schedule');
69
+ });
70
+
71
+ test('cronCadence refuses a step that is not a true period (task 1003674)', () => {
72
+ // A cron step restarts every hour, so only a divisor of 60 is uniform:
73
+ // */7 fires at :00…:56 then waits 4. Calling that "7min" would be the same
74
+ // silent drift between declared and realized period the check is here for.
75
+ for (const expr of ['*/7 * * * *', '*/8 * * * *', '*/45 * * * *', '*/60 * * * *', '*/0 * * * *']) {
76
+ assert.equal(rs.cronCadence(expr), null, `${expr} is not a uniform period`);
77
+ }
78
+ // A step narrowed to one hour or one weekday is not the sub-hourly period it
79
+ // resembles — it only fires inside that window.
80
+ assert.equal(rs.cronCadence('*/15 6 * * *'), null);
81
+ assert.equal(rs.cronCadence('*/15 * * * 1'), null);
82
+ });
83
+
84
+ test('isCadence accepts the named periods and the divisor-of-60 Nmin form (task 1003674)', () => {
85
+ for (const ok of ['hourly', 'daily', 'weekly', 'monthly', '15min', '5min', '30min', '1min']) {
86
+ assert.equal(rs.isCadence(ok), true, `${ok} should be declarable`);
87
+ }
88
+ for (const no of ['7min', '60min', '90min', 'min', '0min', 'fortnightly', '', null, undefined]) {
89
+ assert.equal(rs.isCadence(no), false, `${no} should not be declarable`);
90
+ }
91
+ });
92
+
93
+ test('a routine declaring a sub-hourly cadence matches a step cron that fires it (task 1003674)', () => {
94
+ // End-to-end through evaluate(): declaring 15min is only green when a
95
+ // committed scheduler actually fires it at 15 minutes.
96
+ const wf = (cron) => [{
97
+ file: 'sweep.yml',
98
+ text: `on:\n schedule:\n - cron: '${cron}'\njobs:\n go:\n steps:\n - run: node scripts/gds/run-routine.js sweepy\n`,
99
+ }];
100
+ const declared = routines([{ name: 'sweepy', cadence: '15min', steps: [] }]);
101
+ assert.deepEqual(rs.evaluate(declared, wf('*/15 * * * *')).violations, []);
102
+
103
+ // Wrong period, and the check names both sides rather than shrugging.
104
+ const drifted = rs.evaluate(declared, wf('17 6 * * *')).violations;
105
+ assert.equal(drifted.length, 1);
106
+ assert.match(drifted[0], /declares cadence: 15min/);
107
+ assert.match(drifted[0], /-> daily|→ daily/);
108
+
109
+ // A non-divisor step cannot satisfy it either — it classifies as nothing.
110
+ assert.match(rs.evaluate(declared, wf('*/7 * * * *')).violations[0], /unclassifiable/);
111
+ });
112
+
113
+ test('an undeclarable cadence is refused by name (task 1003674)', () => {
114
+ const v = rs.evaluate(routines([{ name: 'sweepy', cadence: '7min', steps: [] }]), []).violations;
115
+ assert.equal(v.length, 1);
116
+ assert.match(v[0], /unknown cadence "7min"/);
117
+ assert.match(v[0], /N divides 60/);
118
+ });
119
+
60
120
  test('workflowCrons reads every cron a workflow declares, quoted or bare, ignoring trailing comments', () => {
61
121
  const y = [" schedule:", " - cron: '17 6 * * 1'", ' - cron: "0 3 * * *"', ' - cron: 5 * * * * # bare + comment'].join('\n');
62
122
  assert.deepEqual(rs.workflowCrons(y), ['17 6 * * 1', '0 3 * * *', '5 * * * *']);