@bongos/core 1.21.62 → 1.21.64
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 +55 -40
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +7 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +343 -343
- package/docs/copy-registry.json +390 -390
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +753 -728
- package/modules/hall-ui/public/watch.css +15 -0
- package/modules/hall-ui/public/watch.html +3 -0
- package/modules/hall-ui/public/watch.js +87 -1
- package/modules/hall-ui/records/oversight-family.md +6 -0
- package/modules/onboarding/routes/access-requests.js +10 -4
- package/modules/platform-identity/guilds.js +32 -0
- package/modules/platform-identity/routes/guilds.js +10 -3
- package/modules/public-landing/public/assets/cosmos.css +29 -3
- package/modules/public-landing/public/projects.html +294 -4
- package/modules/public-landing/public/projects.probes.json +18 -0
- package/modules/public-landing/public/projects.states.json +3 -0
- package/modules/ui-design/kit/fixtures/guilds.json +7 -0
- package/modules/ui-design/kit/fixtures/me__guild-requests.json +9 -0
- package/modules/ui-design/kit/serve.js +41 -0
- package/package-lock.json +9 -5
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/auth-admission.js +33 -2
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/applicant_view_guild.mjs +101 -0
- package/tests/application_lifecycle.mjs +38 -1
- package/tests/guilds_api.mjs +31 -0
- package/tests/guilds_db.mjs +7 -0
- package/tests/hub_engagement_cards.mjs +9 -6
- package/tests/hub_guild_cards.mjs +392 -0
- package/tests/module_api.mjs +1 -0
- package/tests/watch_applications_queue.mjs +112 -2
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// tests/hub_guild_cards.mjs — a guild's invite to you and a join request to a guild
|
|
2
|
+
// you own, in /projects?view=mine's Invitations, EXECUTED (task 1004583; ADR 0336
|
|
3
|
+
// D3/D4 §3, ADR 0337 D4.4).
|
|
4
|
+
//
|
|
5
|
+
// R24 (task 1002300) shipped the routes — GET /me/guild-requests, POST
|
|
6
|
+
// /guild-requests/:id/accept|decline, GET /guilds/:slug/requests — and nothing in
|
|
7
|
+
// any UI called them. These cards do. What this pins:
|
|
8
|
+
// • an invite card tells the member, BEFORE they accept, that their numbers count
|
|
9
|
+
// toward the guild's rounded totals, in guild.html's own words (the two pages
|
|
10
|
+
// are held to one sentence here), and offers the counted switch right there;
|
|
11
|
+
// picking "Leave them out" repaints the notice and sends nothing
|
|
12
|
+
// • it says what joining shows (a public guild lists you; an unlisted one has no
|
|
13
|
+
// page) and links a public guild's page only
|
|
14
|
+
// • Accept sends exactly { visibility (the card's), counted_in_totals } on the
|
|
15
|
+
// cookie session; a 409 guild_visibility_changed re-draws the card for the
|
|
16
|
+
// guild as it is now, and the next accept sends that
|
|
17
|
+
// • an ended invite or request leaves nothing pressable; an expired session and a
|
|
18
|
+
// dead network are said as such, and the card stays re-tryable
|
|
19
|
+
// • Decline is a body-less POST whose 204 drops the row (the section hides with
|
|
20
|
+
// its last row); a failure keeps the row and says so
|
|
21
|
+
// • the owner's card names who asked, with the handle once, and accepts with no
|
|
22
|
+
// body — the requester's switch rode their request
|
|
23
|
+
// • ONE read feeds both lists — GET /me/guild-requests's incoming and
|
|
24
|
+
// owner_incoming — never one request per guild you own; a failed read is silent
|
|
25
|
+
// • a row the page cannot answer is dropped, never drawn with a button that fails
|
|
26
|
+
// • textContent / createElement only
|
|
27
|
+
//
|
|
28
|
+
// The idiom is tests/hub_engagement_cards.mjs: lift the region out of
|
|
29
|
+
// projects.html, run it in a node:vm under a DOM stub, drive real clicks, and
|
|
30
|
+
// assert the slice boundaries so a rename can never leave this executing nothing.
|
|
31
|
+
//
|
|
32
|
+
// DB-free. Run: node --test tests/hub_guild_cards.mjs
|
|
33
|
+
|
|
34
|
+
import test from 'node:test';
|
|
35
|
+
import assert from 'node:assert/strict';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import vm from 'node:vm';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
43
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
const GUILD_PAGE = fs.readFileSync(path.join(LANDING, 'guild.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
45
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
46
|
+
|
|
47
|
+
// the rows region, mineEl through the guild cards' loaders
|
|
48
|
+
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
49
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadGuildQueue() {')) + 5;
|
|
50
|
+
assert.ok(START > 0, 'the region starts at mineEl');
|
|
51
|
+
assert.ok(END > START, 'and ends with loadGuildQueue');
|
|
52
|
+
const REGION = SCRIPT.slice(START, END);
|
|
53
|
+
for (const fn of ['function paintInvitesSection(', 'function sessionExpired(', 'function guildPost(', 'function guildDecline(',
|
|
54
|
+
'function guildInviteRow(', 'function renderGuildInvites(', 'function guildRequestRow(', 'function renderGuildRequests(',
|
|
55
|
+
'function loadGuildQueue(']) {
|
|
56
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
57
|
+
}
|
|
58
|
+
const CARDS = REGION.slice(REGION.indexOf('var GUILD_COUNTED_ON'));
|
|
59
|
+
assert.ok(CARDS.length > 1000, 'the guild cards are findable');
|
|
60
|
+
|
|
61
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
62
|
+
function el(tag) {
|
|
63
|
+
const n = { tag, children: [], _text: '', className: '', hidden: false, disabled: false, checked: false, value: '', attrs: {}, _on: {}, parent: null };
|
|
64
|
+
Object.defineProperty(n, 'textContent', {
|
|
65
|
+
get() { return n._text + n.children.map((c) => c.textContent).join(''); },
|
|
66
|
+
set(v) { n._text = String(v); n.children = []; },
|
|
67
|
+
});
|
|
68
|
+
n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
|
|
69
|
+
n.remove = () => { if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n); };
|
|
70
|
+
n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
|
|
71
|
+
n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
|
|
72
|
+
n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
|
|
73
|
+
n.querySelector = (sel) => {
|
|
74
|
+
const cls = sel.replace(/^\./, '');
|
|
75
|
+
const walk = (x) => { for (const c of x.children) { if (String(c.className).split(' ').includes(cls)) return c; const d = walk(c); if (d) return d; } return null; };
|
|
76
|
+
return walk(n);
|
|
77
|
+
};
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
const all = (n, pred, out = []) => { for (const c of n.children || []) { if (pred(c)) out.push(c); all(c, pred, out); } return out; };
|
|
81
|
+
|
|
82
|
+
const OPEN_HANDS = { id: '501', created_at: '2026-10-04T15:10:00.000Z', seen_at: null, slug: 'open-hands', name: 'Open Hands', description: 'A newer guild for first-time builders.', visibility: 'public' };
|
|
83
|
+
const QUIET_ROOM = { id: '502', created_at: '2026-10-03T08:45:00.000Z', seen_at: null, slug: 'quiet-room', name: 'Quiet Room', description: '', visibility: 'unlisted' };
|
|
84
|
+
const TOMAS = { id: '601', created_at: '2026-10-04T19:20:00.000Z', seen_at: null, handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } };
|
|
85
|
+
const KAI = { id: '602', created_at: '2026-10-02T11:05:00.000Z', seen_at: null, handle: 'kai-lowe', display_name: null, avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } };
|
|
86
|
+
const OK = { ok: true, status: 200, body: { status: 'accepted' } };
|
|
87
|
+
const GONE = { ok: false, status: 204, body: undefined };
|
|
88
|
+
|
|
89
|
+
function boot({ answers = [] } = {}) {
|
|
90
|
+
const ids = new Map();
|
|
91
|
+
for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
92
|
+
ids.get('mineInvites').hidden = true;
|
|
93
|
+
const requests = [];
|
|
94
|
+
const queued = answers.slice();
|
|
95
|
+
const fetch = (url, init = {}) => {
|
|
96
|
+
requests.push({ url, method: init.method || 'GET', body: init.body, headers: init.headers, credentials: init.credentials });
|
|
97
|
+
const a = typeof queued[0] === 'function' ? queued.shift()(url) : (queued.shift() || OK);
|
|
98
|
+
if (a.reject) return Promise.reject(new Error('offline'));
|
|
99
|
+
const status = a === GONE ? 204 : a.status;
|
|
100
|
+
return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
|
|
101
|
+
};
|
|
102
|
+
const document = { createElement: el, createTextNode: (t) => ({ tag: '#text', textContent: String(t), children: [], className: '' }) };
|
|
103
|
+
const sandbox = {
|
|
104
|
+
document, fetch, console, Promise, JSON, String, Object, Array, URL,
|
|
105
|
+
$: (id) => ids.get(id) || null,
|
|
106
|
+
API: '/api/bongos',
|
|
107
|
+
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
108
|
+
safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
|
|
109
|
+
jsonOrEmpty: (res) => res.json().catch(() => ({})),
|
|
110
|
+
apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} }; },
|
|
111
|
+
};
|
|
112
|
+
vm.createContext(sandbox);
|
|
113
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
|
|
114
|
+
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
115
|
+
const invites = () => ids.get('guildInviteList').children;
|
|
116
|
+
const asks = () => ids.get('guildRequestList').children;
|
|
117
|
+
const part = (card, cls) => card.querySelector('.' + cls);
|
|
118
|
+
const actions = (card) => part(card, 'mineRow__actions').children;
|
|
119
|
+
const button = (card, label) => actions(card).find((b) => b.tag === 'button' && b.textContent === label);
|
|
120
|
+
const radios = (card) => all(card, (n) => n.tag === 'input' && n.type === 'radio');
|
|
121
|
+
const msg = (card) => part(card, 'projMsg');
|
|
122
|
+
const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
|
|
123
|
+
const pick = async (card, value) => {
|
|
124
|
+
for (const r of radios(card)) r.checked = r.value === value;
|
|
125
|
+
for (const f of radios(card).find((r) => r.value === value)._on.change || []) f({});
|
|
126
|
+
await settle();
|
|
127
|
+
};
|
|
128
|
+
return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, section: () => ids.get('mineInvites') };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── the D4.4 notice: one sentence on two pages ───────────────────────────────
|
|
132
|
+
|
|
133
|
+
test('the notice is guild.html’s own words, both ways, byte for byte', () => {
|
|
134
|
+
const said = (src, re) => { const m = src.match(re); assert.ok(m, `found ${re}`); return vm.runInNewContext(m[1]); };
|
|
135
|
+
const onGuild = said(GUILD_PAGE, /\? 'Your numbers are left out[^']*'\s*:\s*('Your numbers count[\s\S]*?share them with\.');/);
|
|
136
|
+
const offGuild = said(GUILD_PAGE, /picked\('counted'\) === 'off'\s*\?\s*('[^']*')/);
|
|
137
|
+
const onHub = said(HUB, /var GUILD_COUNTED_ON = ('[\s\S]*?share them with\.');/);
|
|
138
|
+
const offHub = said(HUB, /var GUILD_COUNTED_OFF = ('[^']*');/);
|
|
139
|
+
assert.equal(onHub, onGuild);
|
|
140
|
+
assert.equal(offHub, offGuild);
|
|
141
|
+
assert.match(onHub, /count toward this guild’s rounded totals/, 'ADR 0337 D4.4’s words');
|
|
142
|
+
// the switch's own labels are the guild page's too
|
|
143
|
+
for (const words of ['Count my numbers', 'They add to this guild’s totals.', 'Leave them out', 'The totals add up everyone else.', 'In this guild’s totals']) {
|
|
144
|
+
assert.ok(GUILD_PAGE.includes(words) && CARDS.includes(words), `"${words}" on both pages`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── the invite card ──────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
test('an invite card says what joining shows, offers the switch set to count, and the notice BEFORE any accept', () => {
|
|
151
|
+
const ui = boot();
|
|
152
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
153
|
+
assert.equal(ui.requests.length, 0, 'drawing sends nothing');
|
|
154
|
+
assert.equal(ui.section().hidden, false);
|
|
155
|
+
const [open, quiet] = ui.invites();
|
|
156
|
+
assert.equal(ui.part(open, 'mineRow__name').textContent, 'You’re invited to join Open Hands');
|
|
157
|
+
assert.equal(ui.part(open, 'guildRow__desc').textContent, 'A newer guild for first-time builders.');
|
|
158
|
+
assert.equal(ui.part(quiet, 'guildRow__desc'), null, 'an empty description draws nothing');
|
|
159
|
+
assert.match(ui.part(open, 'mineRow__hint').textContent, /^It’s public: joining lists your name and picture on its page/);
|
|
160
|
+
assert.match(ui.part(quiet, 'mineRow__hint').textContent, /^It’s unlisted: it has no public page/);
|
|
161
|
+
const link = ui.actions(open).find((a) => a.tag === 'a');
|
|
162
|
+
assert.equal(link.href, '/g/open-hands');
|
|
163
|
+
assert.equal(link.hidden, false);
|
|
164
|
+
assert.equal(ui.actions(quiet).find((a) => a.tag === 'a').hidden, true, 'an unlisted guild has no page to show an invitee');
|
|
165
|
+
for (const card of [open, quiet]) {
|
|
166
|
+
const r = ui.radios(card);
|
|
167
|
+
assert.deepEqual(r.map((x) => [x.value, x.checked]), [['on', true], ['off', false]], 'counted by default, as the membership is');
|
|
168
|
+
assert.equal(new Set(r.map((x) => x.name)).size, 1, 'one group per card');
|
|
169
|
+
const group = ui.part(card, 'guildCount__opts');
|
|
170
|
+
assert.equal(group.getAttribute('role'), 'radiogroup');
|
|
171
|
+
assert.equal(group.getAttribute('aria-labelledby'), ui.part(card, 'guildCount__label').id);
|
|
172
|
+
assert.match(ui.part(card, 'guildCount__what').textContent, /^Your numbers count toward this guild’s rounded totals/);
|
|
173
|
+
assert.deepEqual(ui.actions(card).filter((b) => b.tag === 'button').map((b) => b.textContent), ['Accept', 'Decline']);
|
|
174
|
+
}
|
|
175
|
+
assert.notEqual(ui.radios(open)[0].name, ui.radios(quiet)[0].name, 'two cards never share a group');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('picking Leave them out says what that means before anything is sent; Count brings the notice back', async () => {
|
|
179
|
+
const ui = boot();
|
|
180
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
181
|
+
const card = ui.invites()[0];
|
|
182
|
+
await ui.pick(card, 'off');
|
|
183
|
+
assert.equal(ui.part(card, 'guildCount__what').textContent, 'Your numbers are left out of this guild’s totals, from its next weekly count.');
|
|
184
|
+
await ui.pick(card, 'on');
|
|
185
|
+
assert.match(ui.part(card, 'guildCount__what').textContent, /^Your numbers count toward/);
|
|
186
|
+
assert.equal(ui.requests.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('Accept sends exactly the card’s visibility and the switch, on the cookie session', async () => {
|
|
190
|
+
const ui = boot();
|
|
191
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
192
|
+
const [open, quiet] = ui.invites();
|
|
193
|
+
await ui.click(ui.button(open, 'Accept'));
|
|
194
|
+
await ui.pick(quiet, 'off');
|
|
195
|
+
await ui.click(ui.button(quiet, 'Accept'));
|
|
196
|
+
assert.equal(ui.requests.length, 2);
|
|
197
|
+
const [a, b] = ui.requests;
|
|
198
|
+
assert.equal(a.url, '/api/bongos/guild-requests/501/accept');
|
|
199
|
+
assert.equal(a.method, 'POST');
|
|
200
|
+
assert.equal(a.credentials, 'same-origin');
|
|
201
|
+
assert.equal(a.headers['Content-Type'], 'application/json');
|
|
202
|
+
assert.deepEqual(JSON.parse(a.body), { visibility: 'public', counted_in_totals: true });
|
|
203
|
+
assert.equal(b.url, '/api/bongos/guild-requests/502/accept');
|
|
204
|
+
assert.deepEqual(JSON.parse(b.body), { visibility: 'unlisted', counted_in_totals: false });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('accepted: Joined and no longer the accent, nothing left to press, and the line says where your numbers went', async () => {
|
|
208
|
+
const ui = boot();
|
|
209
|
+
ui.sandbox.renderGuildInvites([QUIET_ROOM]);
|
|
210
|
+
const card = ui.invites()[0];
|
|
211
|
+
await ui.pick(card, 'off');
|
|
212
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
213
|
+
const joined = ui.button(card, 'Joined');
|
|
214
|
+
assert.ok(joined, 'the button says so');
|
|
215
|
+
assert.equal(joined.disabled, true);
|
|
216
|
+
assert.equal(joined.className, 'btn small ghost');
|
|
217
|
+
assert.equal(ui.button(card, 'Decline').hidden, true);
|
|
218
|
+
assert.ok(ui.radios(card).every((r) => r.disabled), 'the switch is set; it moves on the guild page now');
|
|
219
|
+
assert.equal(ui.actions(card).find((a) => a.tag === 'a').hidden, false, 'a member reaches even an unlisted guild’s page');
|
|
220
|
+
assert.equal(ui.msg(card).className, 'projMsg ok');
|
|
221
|
+
assert.equal(ui.msg(card).textContent, 'You’ve joined Quiet Room, and your numbers are left out of its totals.');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('409 guild_visibility_changed: the card re-draws for the guild as it is now, and the next accept sends that', async () => {
|
|
225
|
+
const ui = boot({ answers: [
|
|
226
|
+
{ ok: false, status: 409, body: { error: { code: 'guild_visibility_changed', message: 'guild_visibility_changed', details: { visibility: 'public' } } } },
|
|
227
|
+
OK,
|
|
228
|
+
] });
|
|
229
|
+
ui.sandbox.renderGuildInvites([QUIET_ROOM]);
|
|
230
|
+
const card = ui.invites()[0];
|
|
231
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
232
|
+
assert.match(ui.part(card, 'mineRow__hint').textContent, /^It’s public:/, 'the card now says what joining shows');
|
|
233
|
+
assert.equal(ui.actions(card).find((a) => a.tag === 'a').hidden, false);
|
|
234
|
+
assert.equal(ui.msg(card).textContent, 'This guild changed since the card was drawn: it’s public now. Read it again, then accept.');
|
|
235
|
+
assert.equal(ui.button(card, 'Accept').disabled, false, 'a fresh decision, not a dead end');
|
|
236
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
237
|
+
assert.deepEqual(JSON.parse(ui.requests[1].body), { visibility: 'public', counted_in_totals: true });
|
|
238
|
+
assert.ok(ui.button(card, 'Joined'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('an ended invite leaves nothing pressable; an expired session offers a way back; a dead network is said, and both stay re-tryable', async () => {
|
|
242
|
+
const gone = boot({ answers: [{ ok: false, status: 404, body: { error: { code: 'guild_request_not_found' } } }] });
|
|
243
|
+
gone.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
244
|
+
let card = gone.invites()[0];
|
|
245
|
+
await gone.click(gone.button(card, 'Accept'));
|
|
246
|
+
assert.ok(gone.actions(card).filter((b) => b.tag === 'button').every((b) => b.hidden));
|
|
247
|
+
assert.ok(gone.radios(card).every((r) => r.disabled));
|
|
248
|
+
assert.equal(gone.msg(card).textContent, 'That invite has ended — it was withdrawn, or it ran out.');
|
|
249
|
+
|
|
250
|
+
const out = boot({ answers: [{ ok: false, status: 401, body: { error: { code: 'unauthenticated' } } }, { reject: true }] });
|
|
251
|
+
out.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
252
|
+
card = out.invites()[0];
|
|
253
|
+
await out.click(out.button(card, 'Accept'));
|
|
254
|
+
const a = out.msg(card).children.find((c) => c.tag === 'a');
|
|
255
|
+
assert.ok(a, 'a sign-in link, built as an element');
|
|
256
|
+
assert.equal(a.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1');
|
|
257
|
+
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
258
|
+
await out.click(out.button(card, 'Accept'));
|
|
259
|
+
assert.equal(out.msg(card).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
260
|
+
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
261
|
+
assert.equal(out.button(card, 'Decline').disabled, false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('Decline is a body-less POST whose 204 drops the row, and the last one hides the section; a failure keeps it', async () => {
|
|
265
|
+
const ui = boot({ answers: [GONE, { ok: false, status: 500, body: {} }] });
|
|
266
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
267
|
+
ui.sandbox.renderGuildRequests([TOMAS]);
|
|
268
|
+
await ui.click(ui.button(ui.invites()[0], 'Decline'));
|
|
269
|
+
assert.equal(ui.requests[0].url, '/api/bongos/guild-requests/501/decline');
|
|
270
|
+
assert.equal(ui.requests[0].method, 'POST');
|
|
271
|
+
assert.equal(ui.requests[0].body, undefined, 'nothing to send');
|
|
272
|
+
assert.equal(ui.invites().length, 0);
|
|
273
|
+
assert.equal(ui.section().hidden, false, 'the owner’s card still stands');
|
|
274
|
+
const ask = ui.asks()[0];
|
|
275
|
+
await ui.click(ui.button(ask, 'Decline'));
|
|
276
|
+
assert.equal(ui.asks().length, 1, 'kept on a failure');
|
|
277
|
+
assert.equal(ui.msg(ask).textContent, 'Couldn’t decline just now — try again in a moment.');
|
|
278
|
+
assert.equal(ui.button(ask, 'Decline').disabled, false);
|
|
279
|
+
ui.sandbox.renderGuildRequests([]);
|
|
280
|
+
assert.equal(ui.section().hidden, true, 'no row in any list hides the section');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── the owner's card ─────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
test('a join request names who asked, with the handle once, and accepts with no body', async () => {
|
|
286
|
+
const ui = boot();
|
|
287
|
+
ui.sandbox.renderGuildRequests([TOMAS, KAI]);
|
|
288
|
+
const [tomas, kai] = ui.asks();
|
|
289
|
+
assert.equal(ui.part(tomas, 'mineRow__name').textContent, 'Tomás Ek asked to join Night Shift');
|
|
290
|
+
assert.equal(ui.part(tomas, 'mineRow__host').textContent, '@tomas-ek');
|
|
291
|
+
assert.equal(ui.part(kai, 'mineRow__name').textContent, '@kai-lowe asked to join Night Shift');
|
|
292
|
+
assert.equal(ui.part(kai, 'mineRow__host'), null, 'never the handle twice');
|
|
293
|
+
assert.equal(ui.part(tomas, 'guildCount'), null, 'the requester’s switch rode their request: none here');
|
|
294
|
+
assert.equal(ui.part(tomas, 'mineRow__hint').textContent, 'Accepting makes them a member. A decline is never told to them.');
|
|
295
|
+
await ui.click(ui.button(tomas, 'Accept'));
|
|
296
|
+
assert.equal(ui.requests[0].url, '/api/bongos/guild-requests/601/accept');
|
|
297
|
+
assert.equal(ui.requests[0].body, undefined, 'an owner cannot set the requester’s switch');
|
|
298
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
299
|
+
assert.equal(ui.button(tomas, 'Accepted').className, 'btn small ghost');
|
|
300
|
+
assert.equal(ui.button(tomas, 'Decline').hidden, true);
|
|
301
|
+
assert.equal(ui.msg(tomas).textContent, 'Tomás Ek is in Night Shift now.');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('an ended request leaves nothing pressable; an expired session and a dead network keep the card re-tryable', async () => {
|
|
305
|
+
const ui = boot({ answers: [{ ok: false, status: 404, body: {} }, { ok: false, status: 401, body: {} }, { reject: true }] });
|
|
306
|
+
ui.sandbox.renderGuildRequests([TOMAS, KAI]);
|
|
307
|
+
const [tomas, kai] = ui.asks();
|
|
308
|
+
await ui.click(ui.button(tomas, 'Accept'));
|
|
309
|
+
assert.ok(ui.actions(tomas).every((b) => b.hidden));
|
|
310
|
+
assert.equal(ui.msg(tomas).textContent, 'That request has ended — it was withdrawn, it ran out, or their account is closed.');
|
|
311
|
+
await ui.click(ui.button(kai, 'Accept'));
|
|
312
|
+
assert.ok(ui.msg(kai).children.find((c) => c.tag === 'a'), 'sign in again');
|
|
313
|
+
assert.equal(ui.button(kai, 'Accept').disabled, false);
|
|
314
|
+
await ui.click(ui.button(kai, 'Accept'));
|
|
315
|
+
assert.equal(ui.msg(kai).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
316
|
+
assert.equal(ui.button(kai, 'Accept').disabled, false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── the reads ────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
test('ONE read feeds both lists — the invites to you and the requests to every guild you own — never one per guild', async () => {
|
|
322
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: {
|
|
323
|
+
incoming: [OPEN_HANDS], outgoing: [{ id: '9', slug: 'x' }], owner_incoming: [TOMAS, KAI],
|
|
324
|
+
incoming_count: 1, outgoing_count: 1, owner_incoming_count: 2,
|
|
325
|
+
} }] });
|
|
326
|
+
await ui.sandbox.loadGuildQueue();
|
|
327
|
+
await ui.settle();
|
|
328
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`), ['GET /api/bongos/me/guild-requests']);
|
|
329
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
330
|
+
assert.equal(ui.invites().length, 1, 'incoming only: the requests YOU sent are not cards here');
|
|
331
|
+
assert.equal(ui.asks().length, 2);
|
|
332
|
+
assert.equal(ui.part(ui.asks()[0], 'mineRow__name').textContent, 'Tomás Ek asked to join Night Shift', 'each row names its own guild');
|
|
333
|
+
assert.doesNotMatch(CARDS, /\/me\/guilds'|\/requests'/, 'no per-guild queue read anywhere in the cards');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('a failed or empty read is silent: no cards, and the section stays hidden', async () => {
|
|
337
|
+
for (const answer of [{ ok: false, status: 500, body: {} }, { reject: true }, { ok: true, status: 200, body: {} }]) {
|
|
338
|
+
const ui = boot({ answers: [answer] });
|
|
339
|
+
await ui.sandbox.loadGuildQueue();
|
|
340
|
+
await ui.settle();
|
|
341
|
+
assert.equal(ui.invites().length + ui.asks().length, 0);
|
|
342
|
+
assert.equal(ui.section().hidden, true);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('a row the page cannot answer is dropped, never drawn with a button that fails', () => {
|
|
347
|
+
const ui = boot();
|
|
348
|
+
ui.sandbox.renderGuildInvites([
|
|
349
|
+
{ ...OPEN_HANDS, id: 'abc' }, { ...OPEN_HANDS, id: undefined }, { ...OPEN_HANDS, slug: '' },
|
|
350
|
+
{ ...OPEN_HANDS, visibility: 'secret' }, null, QUIET_ROOM,
|
|
351
|
+
]);
|
|
352
|
+
assert.deepEqual(ui.invites().map((c) => ui.part(c, 'mineRow__name').textContent), ['You’re invited to join Quiet Room']);
|
|
353
|
+
ui.sandbox.renderGuildRequests([{ ...TOMAS, id: '1x' }, { ...TOMAS, handle: '' }, { ...TOMAS, guild: null }, KAI]);
|
|
354
|
+
assert.equal(ui.asks().length, 1);
|
|
355
|
+
// within the bigint range the route checks (routes/guilds.js requestId), and no further
|
|
356
|
+
ui.sandbox.renderGuildRequests([{ ...TOMAS, id: '9223372036854775808' }, { ...KAI, id: '9223372036854775807' }]);
|
|
357
|
+
assert.deepEqual(ui.asks().map((c) => ui.part(c, 'mineRow__name').textContent), ['@kai-lowe asked to join Night Shift']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── the markup, the region's rules, the kit ──────────────────────────────────
|
|
361
|
+
|
|
362
|
+
test('the cards are real lists in the Invitations section, built with createElement / textContent only', () => {
|
|
363
|
+
const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
364
|
+
const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
|
|
365
|
+
assert.ok(order.every((i) => i > 0), 'each list ships as real markup the script only fills');
|
|
366
|
+
assert.deepEqual([...order].sort((a, b) => a - b), order, 'invites to you first, the requests to your guilds last');
|
|
367
|
+
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
368
|
+
assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\);/, 'the mine view reads them with the other invitations');
|
|
369
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
370
|
+
for (const rule of ['.guildCount__opts{', '.guildCount__opt:has(input:checked) b::before{', '.guildCount__opt:focus-within{', '.guildRow--invite .mineRow__actions{align-self:flex-end;}']) {
|
|
371
|
+
assert.ok(COSMOS.includes(rule), `cosmos.css carries ${rule}`);
|
|
372
|
+
}
|
|
373
|
+
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /guildCount|guildRow/, 'styled in cosmos.css, not in the page head');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('the kit pictures the cards, and the stub answers the four calls as the routes do', () => {
|
|
377
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
378
|
+
for (const s of ['in-mine-guild-cards', 'in-mine-guild-count-off', 'in-mine-guild-joined']) assert.ok(states[s], `kit state ${s}`);
|
|
379
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes.map((p) => p.name);
|
|
380
|
+
assert.ok(probes.some((n) => /guild invite/.test(n)) && probes.some((n) => /join request/.test(n)), 'probes drive both cards');
|
|
381
|
+
const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
382
|
+
assert.match(serve, /rest === '\/me\/guild-requests' && req\.method === 'GET'\) return AUTH \? myGuildRequests\(res\)/);
|
|
383
|
+
assert.match(serve, /owner_incoming: owner, owner_incoming_count: owner\.length/, 'the route’s one-read shape, owner rows included');
|
|
384
|
+
assert.match(serve, /'guild_card_visibility_required'/, 'an invitee accept with no visibility is refused, as the route does');
|
|
385
|
+
assert.match(serve, /code: 'guild_visibility_changed'[^\n]*details: \{ visibility: now \}/, 'and one that differs is the 409 with the guild as it is now');
|
|
386
|
+
assert.match(serve, /res\.writeHead\(204/, 'a decline is a silent 204');
|
|
387
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'me__guild-requests.json'), 'utf8'));
|
|
388
|
+
for (const r of fixture.incoming) {
|
|
389
|
+
assert.deepEqual(Object.keys(r).sort(), ['created_at', 'description', 'id', 'name', 'seen_at', 'slug', 'visibility'], 'listMyRequests’s row shape');
|
|
390
|
+
}
|
|
391
|
+
assert.deepEqual(fixture.incoming.map((r) => r.visibility).sort(), ['public', 'unlisted']);
|
|
392
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -48,6 +48,7 @@ const PUBLISHED_SURFACE = [
|
|
|
48
48
|
'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
|
|
49
49
|
'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
|
|
50
50
|
'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
|
|
51
|
+
'readApplicantViewFromHub', // added by task 1002304 (ADR 0365 D5): the same NARROW hub read as readApplicantProfileFromHub, answering { profile, guild } so the reviewer queue can group a guild's applicants. The guild rides only beside a profile, and the older port is kept so a module on it reads exactly what it did
|
|
51
52
|
'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
|
|
52
53
|
'inviteExpiry', // added by task 1002969 (ADR 0356): the ONE read-time definition of when a recruit invite's door closes. The sign-in gate reads it in core; the onboarding queues and the hub's notice list are modules, so without the doorway each would restate the 30 days and the predicate, and two copies can close a door on different days
|
|
53
54
|
'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
|
|
@@ -93,7 +93,8 @@ async function boot(opts = {}) {
|
|
|
93
93
|
rows = { pending: WAITING, invited: ADMITTED, dismissed: DECLINED },
|
|
94
94
|
rank = 'archon',
|
|
95
95
|
queueStatus = null, // non-200 for the queue read (403 seals it)
|
|
96
|
-
patchResult = null, // a scripted answer for PATCH /access-requests/:id
|
|
96
|
+
patchResult = null, // a scripted answer for PATCH /access-requests/:id (or a function of the id)
|
|
97
|
+
hub = null, // the hub origin OTB.hubOrigin() answers (task 1002304); null = no hub
|
|
97
98
|
search = '', // the page's query string (?invite=<login>, task 1002818)
|
|
98
99
|
} = opts;
|
|
99
100
|
|
|
@@ -114,7 +115,9 @@ async function boot(opts = {}) {
|
|
|
114
115
|
return ok({ requests: rows[status] || [] });
|
|
115
116
|
}
|
|
116
117
|
if (method === 'PATCH' && route.startsWith('/access-requests/')) {
|
|
117
|
-
|
|
118
|
+
const id = route.split('/').pop();
|
|
119
|
+
const scripted = typeof patchResult === 'function' ? patchResult(id) : patchResult;
|
|
120
|
+
return scripted || ok({ ok: true, request: { id } });
|
|
118
121
|
}
|
|
119
122
|
if (method === 'POST' && route === '/access-requests/invite') return ok({ ok: true });
|
|
120
123
|
// Every other panel on this page loads independently and degrades on its
|
|
@@ -175,6 +178,7 @@ async function boot(opts = {}) {
|
|
|
175
178
|
fmtDate: (iso) => String(iso).slice(0, 10),
|
|
176
179
|
rankLabel: (r) => String(r),
|
|
177
180
|
toast: (m) => toasts.push(m),
|
|
181
|
+
hubOrigin: () => hub,
|
|
178
182
|
},
|
|
179
183
|
OTBKit: kit,
|
|
180
184
|
OTBBranding: { currencyLabel: () => 'Credits' },
|
|
@@ -522,4 +526,110 @@ await test('?invite= that is not a GitHub username fills nothing', async () => {
|
|
|
522
526
|
assert.equal(at.documentObj.getElementById('gate-invite-login').value, 'octo', 'a pasted @ is dropped, as the form itself does');
|
|
523
527
|
});
|
|
524
528
|
|
|
529
|
+
// ===========================================================================
|
|
530
|
+
// A guild's applicants, together (task 1002304, R28; ADR 0365 D5)
|
|
531
|
+
// ===========================================================================
|
|
532
|
+
//
|
|
533
|
+
// The hub labels an applicant who applied WITH their guild, and core passes the
|
|
534
|
+
// label through as `guild` beside `profile` (tests/application_lifecycle.mjs
|
|
535
|
+
// proves that half). What a reviewer sees is asserted here: the rows gathered
|
|
536
|
+
// under the guild's name, "Invite all" as N ordinary invites, and the queue
|
|
537
|
+
// exactly as it was when no row carries a guild.
|
|
538
|
+
|
|
539
|
+
const LANTERN = { slug: 'lantern-works', name: 'Lantern Works' };
|
|
540
|
+
const guildRow = (id, login, guild, over = {}) => ({
|
|
541
|
+
id, github_login: login, display_name: null, note: null, status: 'pending',
|
|
542
|
+
created_at: '2026-09-03T10:00:00Z', resolved_at: null, vouched: true, resolved_by_login: null,
|
|
543
|
+
profile: null, guild, ...over,
|
|
544
|
+
});
|
|
545
|
+
const GUILD_QUEUE = [
|
|
546
|
+
guildRow('21', 'ada', LANTERN),
|
|
547
|
+
guildRow('22', 'kel', LANTERN),
|
|
548
|
+
guildRow('23', 'fox', { slug: null, name: 'Night Shift' }),
|
|
549
|
+
WAITING[1],
|
|
550
|
+
];
|
|
551
|
+
const groupsOf = (h) => h.gate().querySelectorAll('section').filter((s) => String(s.className).split(/\s+/).includes('ov-group'));
|
|
552
|
+
const groupNamed = (h, name) => groupsOf(h).find((g) => g.querySelector('.ov-group__name').textContent === name);
|
|
553
|
+
const rowIdsIn = (el) => el.querySelectorAll('li').map((r) => r.getAttribute('data-req-id'));
|
|
554
|
+
|
|
555
|
+
await test('applicants who applied with a guild are gathered under its name, the rest on their own', async () => {
|
|
556
|
+
const h = await boot({ rows: { pending: GUILD_QUEUE, invited: [], dismissed: [] } });
|
|
557
|
+
const lantern = groupNamed(h, 'Lantern Works');
|
|
558
|
+
assert.ok(lantern, 'the guild has a head of its own');
|
|
559
|
+
assert.deepEqual(rowIdsIn(lantern), ['21', '22'], 'both members, in the server order');
|
|
560
|
+
assert.equal(lantern.querySelector('.ov-group__n').textContent, '2');
|
|
561
|
+
assert.deepEqual(rowIdsIn(groupNamed(h, 'Night Shift')), ['23'], 'an unlisted guild is named too');
|
|
562
|
+
assert.deepEqual(rowIdsIn(groupNamed(h, 'On their own')), ['8'], 'a row with no guild stays out of every guild');
|
|
563
|
+
assert.equal(h.rows().length, 4, 'grouping moves rows, it never drops one');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
await test('no row carries a guild (an older hub, no hub): the queue is exactly the list it was', async () => {
|
|
567
|
+
const h = await boot();
|
|
568
|
+
assert.equal(groupsOf(h).length, 0, 'no group heads at all when there is nothing to group');
|
|
569
|
+
assert.equal(h.gate().querySelectorAll('ul').filter((u) => u.className === 'ov-rows').length, 1);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await test('an UNVOUCHED row naming a guild is not filed under it — the label is the vouch’s to give', async () => {
|
|
573
|
+
const impostor = guildRow('31', 'mallory', LANTERN, { vouched: false });
|
|
574
|
+
const h = await boot({ rows: { pending: [guildRow('21', 'ada', LANTERN), impostor], invited: [], dismissed: [] } });
|
|
575
|
+
assert.deepEqual(rowIdsIn(groupNamed(h, 'Lantern Works')), ['21']);
|
|
576
|
+
assert.deepEqual(rowIdsIn(groupNamed(h, 'On their own')), ['31']);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await test('a grouped row keeps its own pill and its own two decisions (ADR 0016)', async () => {
|
|
580
|
+
const h = await boot({ rows: { pending: GUILD_QUEUE, invited: [], dismissed: [] } });
|
|
581
|
+
const verbs = h.rowFor('21').querySelectorAll('[data-gate-action]').map((b) => b.getAttribute('data-gate-action'));
|
|
582
|
+
assert.deepEqual(verbs, ['invited', 'dismissed']);
|
|
583
|
+
assert.match(h.rowFor('21').querySelector('.fact-pill').textContent, /vouched/i);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
await test('"Invite all" is N ordinary invites — one PATCH per waiting member, then one re-read', async () => {
|
|
587
|
+
const h = await boot({ rows: { pending: GUILD_QUEUE, invited: [], dismissed: [] } });
|
|
588
|
+
const btn = groupNamed(h, 'Lantern Works').querySelector('[data-gate-bulk]');
|
|
589
|
+
assert.ok(btn, 'a guild of two offers Invite all');
|
|
590
|
+
assert.match(btn.textContent, /Invite all 2/);
|
|
591
|
+
const reads = h.queueReads().length;
|
|
592
|
+
btn.fire('click', {});
|
|
593
|
+
await h.settle();
|
|
594
|
+
assert.deepEqual(h.patches().map((p) => p.path).sort(),
|
|
595
|
+
['/api/bongos/access-requests/21', '/api/bongos/access-requests/22'], 'each member, and nobody else');
|
|
596
|
+
for (const p of h.patches()) assert.deepEqual(plain(p.body), { status: 'invited' }, 'the very write the row button sends');
|
|
597
|
+
assert.equal(h.queueReads().length, reads + 1, 'the queue is re-read once, after every invite settled');
|
|
598
|
+
assert.match(h.toasts.at(-1), /Invited all 2/);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await test('one refused invite does not strand the rest, and the toast says how many landed', async () => {
|
|
602
|
+
const h = await boot({
|
|
603
|
+
rows: { pending: GUILD_QUEUE, invited: [], dismissed: [] },
|
|
604
|
+
patchResult: (id) => (id === '22' ? fail(409, 'already_member', 'They have already signed in.') : null),
|
|
605
|
+
});
|
|
606
|
+
groupNamed(h, 'Lantern Works').querySelector('[data-gate-bulk]').fire('click', {});
|
|
607
|
+
await h.settle();
|
|
608
|
+
assert.equal(h.patches().length, 2, 'both invites were sent');
|
|
609
|
+
assert.match(h.toasts.at(-1), /Invited 1 of 2/);
|
|
610
|
+
assert.match(h.toasts.at(-1), /already signed in/, 'the server sentence, not [object Object]');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
await test('a lone member has no "Invite all", and the decided views offer no bulk move', async () => {
|
|
614
|
+
const h = await boot({ rows: { pending: GUILD_QUEUE, invited: GUILD_QUEUE.map((r) => ({ ...r, status: 'invited' })), dismissed: [] } });
|
|
615
|
+
assert.equal(groupNamed(h, 'Night Shift').querySelector('[data-gate-bulk]'), null, 'one applicant already has their own Invite');
|
|
616
|
+
await h.switchView('invited');
|
|
617
|
+
assert.ok(groupNamed(h, 'Lantern Works'), 'the history is still grouped by guild');
|
|
618
|
+
assert.equal(h.gate().querySelector('[data-gate-bulk]'), null, 'a mass rescind is not a gesture this queue makes');
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
await test('the guild name links to its hub page only when it has one, and is always escaped', async () => {
|
|
622
|
+
const hostile = { slug: 'lantern-works', name: '<img src=x onerror=alert(1)>Lantern' };
|
|
623
|
+
const rows = [guildRow('21', 'ada', hostile), guildRow('23', 'fox', { slug: null, name: 'Night Shift' })];
|
|
624
|
+
const linked = await boot({ hub: 'https://hub.example', rows: { pending: rows, invited: [], dismissed: [] } });
|
|
625
|
+
const a = linked.gate().querySelectorAll('a').find((x) => String(x.className).includes('gate-guild__link'));
|
|
626
|
+
assert.ok(a, 'a public guild links to its page on the hub');
|
|
627
|
+
assert.equal(a.getAttribute('href'), 'https://hub.example/g/lantern-works');
|
|
628
|
+
assert.equal(linked.gate().querySelectorAll('img').length, 0, 'a guild name is user text on a page an Archon reads');
|
|
629
|
+
assert.equal(groupNamed(linked, 'Night Shift').querySelectorAll('a').length, 0, 'an unlisted guild has no page to open');
|
|
630
|
+
const noHub = await boot({ rows: { pending: rows, invited: [], dismissed: [] } });
|
|
631
|
+
assert.equal(noHub.gate().querySelectorAll('a').filter((x) => String(x.className).includes('gate-guild__link')).length, 0,
|
|
632
|
+
'no hub, no link — never a same-host URL claiming to be the hub');
|
|
633
|
+
});
|
|
634
|
+
|
|
525
635
|
summary();
|