@bongos/core 1.21.56 → 1.21.58
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 +106 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +13 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
- package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +233 -6
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +361 -315
- package/docs/copy-registry.json +868 -419
- package/docs/file-map.md +4 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +856 -771
- package/modules/platform-identity/application-echo.js +11 -5
- package/modules/platform-identity/guild-engagements.js +255 -0
- package/modules/platform-identity/guilds.js +3 -1
- package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
- package/modules/platform-identity/routes/guilds.js +35 -1
- package/modules/platform-identity/routes/my-projects.js +27 -3
- package/modules/platform-identity/routes/projects.js +44 -0
- package/modules/platform-identity/routes/sso.js +21 -3
- package/modules/public-landing/public/assets/cosmos.css +7 -0
- package/modules/public-landing/public/guild.html +633 -0
- package/modules/public-landing/public/guild.probes.json +63 -0
- package/modules/public-landing/public/guild.states.json +43 -0
- package/modules/public-landing/public/projects.html +252 -16
- package/modules/public-landing/public/projects.probes.json +26 -1
- package/modules/public-landing/public/projects.states.json +4 -0
- package/modules/ui-design/kit/fixtures/guilds.json +73 -0
- package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
- package/modules/ui-design/kit/serve.js +99 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +13 -0
- package/tests/applicant_profile_boundary.mjs +104 -1
- package/tests/application_echo.mjs +91 -5
- package/tests/guild_engagement.mjs +881 -0
- package/tests/guild_engagement_db.mjs +240 -0
- package/tests/hub_engagement_cards.mjs +307 -0
- package/tests/hub_guild_page.mjs +624 -0
- package/tests/privacy_matrix.mjs +216 -14
- package/tests/project_invite_ui.mjs +187 -3
- package/tests/render_check.mjs +2 -2
- package/tests/ui_design_kit.mjs +3 -3
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// tests/guild_engagement_db.mjs — guild engagements against REAL SQL (task
|
|
2
|
+
// 1002303, R27; ADR 0336 D7 as amended by ADR 0365).
|
|
3
|
+
//
|
|
4
|
+
// Why a real database: the rules that matter here live in the schema and in
|
|
5
|
+
// single statements a fake pool cannot execute — the FK that cascades a
|
|
6
|
+
// dissolved guild's engagements, the one that SETs NULL on the echo instead,
|
|
7
|
+
// the upsert that restarts the 30-day window in place, the read-time window
|
|
8
|
+
// itself, the joined_at rule that keeps a later joiner from inheriting an
|
|
9
|
+
// invite, and the owner test and naming rule riding inside one INSERT. The
|
|
10
|
+
// DB-free sibling, tests/guild_engagement.mjs, covers the route layer and the
|
|
11
|
+
// statement shapes.
|
|
12
|
+
//
|
|
13
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
14
|
+
// INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
|
|
15
|
+
// high-numbered, every guild slug starts r27-, every client id r27- and every
|
|
16
|
+
// origin https://r27-…, so a shared DB is safe; all fixtures are deleted before
|
|
17
|
+
// and after.
|
|
18
|
+
//
|
|
19
|
+
// Run: DATABASE_URL=... node tests/guild_engagement_db.mjs
|
|
20
|
+
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const { Pool } = require('pg');
|
|
29
|
+
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
32
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
33
|
+
|
|
34
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
35
|
+
try {
|
|
36
|
+
await pool.query('SELECT 1');
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.log(`guild_engagement_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
39
|
+
await pool.end().catch(() => {});
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
44
|
+
const engagements = require('../modules/platform-identity/guild-engagements.js');
|
|
45
|
+
const { recordApplication } = require('../modules/platform-identity/application-echo.js');
|
|
46
|
+
|
|
47
|
+
const OWNER = 9002303001; // public, active — owns the guild
|
|
48
|
+
const MEMBER = 9002303002; // public, active — a member from long ago
|
|
49
|
+
const LATE = 9002303003; // public, active — joins after the project invites the guild
|
|
50
|
+
const PRIVM = 9002303004; // PRIVATE account, a shown member
|
|
51
|
+
const SHYM = 9002303005; // public account, a member NOT shown on the page
|
|
52
|
+
const ALL_IDS = [OWNER, MEMBER, LATE, PRIVM, SHYM];
|
|
53
|
+
|
|
54
|
+
const ORIGIN = (k) => `https://r27-${k}.example`;
|
|
55
|
+
const PROJECTS = {
|
|
56
|
+
pub: { visibility: 'public' },
|
|
57
|
+
inv: { visibility: 'public' },
|
|
58
|
+
priv: { visibility: 'private' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
62
|
+
async function cleanup() {
|
|
63
|
+
await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r27-%'`).catch(() => {});
|
|
64
|
+
await q(`DELETE FROM platform_identity_guild_engagements WHERE client_id LIKE 'r27-%'`).catch(() => {});
|
|
65
|
+
await q(`DELETE FROM platform_identity_application_echoes WHERE client_id LIKE 'r27-%'`).catch(() => {});
|
|
66
|
+
await q(`DELETE FROM platform_identity_project_memberships WHERE client_id LIKE 'r27-%'`).catch(() => {});
|
|
67
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin LIKE 'https://r27-%'`).catch(() => {});
|
|
68
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE client_id LIKE 'r27-%'`).catch(() => {});
|
|
69
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let pass = 0;
|
|
73
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
74
|
+
async function rejects(promise, code, msg) {
|
|
75
|
+
let caught = null;
|
|
76
|
+
try { await promise; } catch (e) { caught = e; }
|
|
77
|
+
ok(caught && caught.code === code, `${msg} (expected ${code}, got ${caught ? caught.code : 'no error'})`);
|
|
78
|
+
}
|
|
79
|
+
const engagementRows = async (clientId) => (await q(
|
|
80
|
+
`SELECT kind, created_at FROM platform_identity_guild_engagements WHERE client_id = $1 ORDER BY kind`, [clientId])).rows;
|
|
81
|
+
const inboxOf = async (gid) => (await engagements.listMyEngagements({ githubId: gid }, { pool }))
|
|
82
|
+
.map((e) => `${e.guild.slug}|${e.project.origin.replace('https://r27-', '').replace('.example', '')}|${e.kind}`);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// The migrations this domain reads, all idempotent: accounts + consent,
|
|
86
|
+
// handles, privacy, terms; clients, memberships and the project catalog with
|
|
87
|
+
// its door columns; the echo; guilds; and the engagements.
|
|
88
|
+
for (const f of [
|
|
89
|
+
'platform_identity_001_tables.sql', 'platform_identity_003_project_memberships.sql',
|
|
90
|
+
'platform_identity_004_projects.sql', 'platform_identity_007_profile_consent.sql',
|
|
91
|
+
'platform_identity_008_handle.sql', 'platform_identity_011_project_visibility.sql',
|
|
92
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_016_application_echo.sql',
|
|
93
|
+
'platform_identity_019_terms_acceptance.sql', 'platform_identity_020_visibility_pull.sql',
|
|
94
|
+
'platform_identity_025_guilds.sql', 'platform_identity_030_guild_engagements.sql',
|
|
95
|
+
]) await q(migrationSql(f));
|
|
96
|
+
// The membership_kind rename (010 expand, 021 contract) is not re-runnable once
|
|
97
|
+
// contracted, so it is applied only to a table still in its 003 shape. Both
|
|
98
|
+
// record themselves in the core migrations ledger, which a scratch DB lacks.
|
|
99
|
+
const renamed = (await q(`SELECT 1 FROM information_schema.columns
|
|
100
|
+
WHERE table_name = 'platform_identity_project_memberships' AND column_name = 'membership_kind'`)).rowCount > 0;
|
|
101
|
+
if (!renamed) {
|
|
102
|
+
await q('CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
|
|
103
|
+
for (const f of ['platform_identity_010_membership_kind.sql', 'platform_identity_021_drop_legacy_role.sql']) await q(migrationSql(f));
|
|
104
|
+
}
|
|
105
|
+
await q(migrationSql('platform_identity_030_guild_engagements.sql')); // re-runnable
|
|
106
|
+
await cleanup();
|
|
107
|
+
|
|
108
|
+
// --- 1. the schema holds its own rules ------------------------------------
|
|
109
|
+
const cols = (await q(
|
|
110
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = 'platform_identity_guild_engagements' ORDER BY ordinal_position`)).rows
|
|
111
|
+
.map((r) => r.column_name);
|
|
112
|
+
ok(JSON.stringify(cols) === JSON.stringify(['guild_id', 'client_id', 'kind', 'created_at']),
|
|
113
|
+
'the engagement table is (guild_id, client_id, kind, created_at): no filer, no status (ADR 0201 D1.3)');
|
|
114
|
+
const fk = async (table, column) => (await q(
|
|
115
|
+
`SELECT c.confdeltype FROM pg_constraint c
|
|
116
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
|
117
|
+
WHERE c.contype = 'f' AND c.conrelid = $1::regclass AND a.attname = $2`, [table, column])).rows[0]?.confdeltype;
|
|
118
|
+
ok(await fk('platform_identity_guild_engagements', 'guild_id') === 'c', 'the engagement FK CASCADES on dissolve');
|
|
119
|
+
ok(await fk('platform_identity_application_echoes', 'guild_id') === 'n', 'the echo FK SETs NULL on dissolve');
|
|
120
|
+
const echoCol = (await q(
|
|
121
|
+
`SELECT is_nullable FROM information_schema.columns WHERE table_name = 'platform_identity_application_echoes' AND column_name = 'guild_id'`)).rows[0];
|
|
122
|
+
ok(echoCol && echoCol.is_nullable === 'YES', 'the echo\'s guild_id is nullable — a solo application has none');
|
|
123
|
+
|
|
124
|
+
// --- fixtures ---------------------------------------------------------------
|
|
125
|
+
await q(
|
|
126
|
+
`INSERT INTO platform_identity_accounts
|
|
127
|
+
(github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility)
|
|
128
|
+
VALUES ($1, 'r27own', 'r27own', 'Owner', 'active', now(), 'public'),
|
|
129
|
+
($2, 'r27mem', 'r27mem', 'Member', 'active', now(), 'public'),
|
|
130
|
+
($3, 'r27late', 'r27late', 'Late', 'active', now(), 'public'),
|
|
131
|
+
($4, 'r27privm', 'r27privm', 'PrivM', 'active', now(), 'private'),
|
|
132
|
+
($5, 'r27shym', 'r27shym', 'ShyM', 'active', now(), 'public')`,
|
|
133
|
+
ALL_IDS,
|
|
134
|
+
);
|
|
135
|
+
for (const [k, p] of Object.entries(PROJECTS)) {
|
|
136
|
+
await q(`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, redirect_uris, origin, status)
|
|
137
|
+
VALUES ($1, '', $2, '{}', $3, 'active')`, [`r27-${k}`, `R27 ${k}`, ORIGIN(k)]);
|
|
138
|
+
await q(`INSERT INTO platform_identity_projects (origin, name, description, visibility, visibility_source, status)
|
|
139
|
+
VALUES ($1, $2, 'a real description', $3, 'control-plane', 'active')`, [ORIGIN(k), `R27 Project ${k}`, p.visibility]);
|
|
140
|
+
}
|
|
141
|
+
const created = await guilds.createGuild({ githubId: OWNER, slug: 'r27-guild', name: 'R27 Guild', visibility: 'public' }, { pool });
|
|
142
|
+
ok(created.guild && created.guild.slug === 'r27-guild', 'the guild exists, its creator its owner');
|
|
143
|
+
const gid = (await q(`SELECT id FROM platform_identity_guilds WHERE slug = 'r27-guild'`)).rows[0].id;
|
|
144
|
+
await q(`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly, joined_at)
|
|
145
|
+
VALUES ($1, $2, 'member', true, now() - interval '100 days'),
|
|
146
|
+
($1, $3, 'member', true, now() - interval '90 days'),
|
|
147
|
+
($1, $4, 'member', false, now() - interval '90 days')`, [gid, MEMBER, PRIVM, SHYM]);
|
|
148
|
+
|
|
149
|
+
await rejects(q(`INSERT INTO platform_identity_guild_engagements (guild_id, client_id, kind) VALUES ($1, 'r27-pub', 'pending')`, [gid]),
|
|
150
|
+
'23514', 'kind is apply or invited, nothing else');
|
|
151
|
+
|
|
152
|
+
// --- 2. filing: the owner, a named project, one statement --------------------
|
|
153
|
+
const filed = await engagements.fileEngagement({ guildId: gid, githubId: OWNER, origin: `${ORIGIN('pub')}/` }, { pool });
|
|
154
|
+
ok(filed && filed.kind === 'apply' && filed.project.origin === ORIGIN('pub') && filed.guild.slug === 'r27-guild',
|
|
155
|
+
'the owner files "this guild is applying to P"');
|
|
156
|
+
ok(await engagements.fileEngagement({ guildId: gid, githubId: MEMBER, origin: ORIGIN('inv') }, { pool }) === null
|
|
157
|
+
&& (await engagementRows('r27-inv')).length === 0,
|
|
158
|
+
'a member who is not the owner files nothing — the owner test is in the INSERT');
|
|
159
|
+
ok(await engagements.fileEngagement({ guildId: gid, githubId: OWNER, origin: ORIGIN('priv') }, { pool }) === null
|
|
160
|
+
&& (await engagementRows('r27-priv')).length === 0,
|
|
161
|
+
'a private project cannot be named, so it cannot be applied to as a guild (the naming rule at filing)');
|
|
162
|
+
ok(await engagements.fileEngagement({ guildId: gid, githubId: OWNER, origin: 'https://r27-nowhere.example' }, { pool }) === null,
|
|
163
|
+
'an unknown origin is the same null');
|
|
164
|
+
await rejects(q(`INSERT INTO platform_identity_guild_engagements (guild_id, client_id, kind) VALUES ($1, 'r27-pub', 'apply')`, [gid]),
|
|
165
|
+
'23505', 'the key is (guild, project, kind): a second row for the same act is a database error');
|
|
166
|
+
|
|
167
|
+
// --- 3. the window is read-time, and re-filing restarts it ------------------
|
|
168
|
+
ok((await inboxOf(MEMBER)).includes('r27-guild|pub|apply'), 'a member sees the apply engagement in their inbox');
|
|
169
|
+
await q(`UPDATE platform_identity_guild_engagements SET created_at = now() - interval '31 days' WHERE client_id = 'r27-pub'`);
|
|
170
|
+
ok(!(await inboxOf(MEMBER)).includes('r27-guild|pub|apply'), 'a row older than 30 days is invisible …');
|
|
171
|
+
ok((await engagementRows('r27-pub')).length === 1, '… and still there: no sweep, nothing expired');
|
|
172
|
+
ok(await engagements.engagementFor({ githubId: MEMBER, slug: 'r27-guild', clientId: 'r27-pub' }, { pool }) === null,
|
|
173
|
+
'an aged-out engagement no longer lets anyone apply with the guild');
|
|
174
|
+
await engagements.fileEngagement({ guildId: gid, githubId: OWNER, origin: ORIGIN('pub') }, { pool });
|
|
175
|
+
const restarted = await engagementRows('r27-pub');
|
|
176
|
+
ok(restarted.length === 1 && Date.now() - new Date(restarted[0].created_at).getTime() < 60_000,
|
|
177
|
+
're-filing restarts the window IN PLACE — one row, created_at now');
|
|
178
|
+
ok((await inboxOf(MEMBER)).includes('r27-guild|pub|apply'), 'and the card is back');
|
|
179
|
+
|
|
180
|
+
// --- 4. a project invites the guild: links to the public roster, a notice for the rest
|
|
181
|
+
const inv = await engagements.inviteGuild({ clientId: 'r27-inv', origin: ORIGIN('inv'), slug: 'r27-guild' }, { pool });
|
|
182
|
+
ok(inv && JSON.stringify(inv.invites.map((i) => i.handle)) === JSON.stringify(['r27own', 'r27mem']),
|
|
183
|
+
'one hall link per member on the PUBLIC roster: the private account and the unshown member are not on it');
|
|
184
|
+
ok(inv.invites.every((i) => i.invite_url.startsWith(`${ORIGIN('inv')}/builders/watch?invite=r27`)), 'each link is the project\'s own hall');
|
|
185
|
+
ok((await engagementRows('r27-inv')).map((r) => r.kind).join() === 'invited', 'the invited engagement is recorded');
|
|
186
|
+
const privInv = await engagements.inviteGuild({ clientId: 'r27-priv', origin: ORIGIN('priv'), slug: 'r27-guild' }, { pool });
|
|
187
|
+
ok(privInv.invites.length === 2 && (await engagementRows('r27-priv')).length === 0,
|
|
188
|
+
'a private project still gets its links, but leaves no notice for the members it could not see');
|
|
189
|
+
await q(`UPDATE platform_identity_guilds SET visibility = 'unlisted' WHERE id = $1`, [gid]);
|
|
190
|
+
ok(await engagements.inviteGuild({ clientId: 'r27-inv', origin: ORIGIN('inv'), slug: 'r27-guild' }, { pool }) === null,
|
|
191
|
+
'an unlisted guild cannot be invited');
|
|
192
|
+
await q(`UPDATE platform_identity_guilds SET visibility = 'public' WHERE id = $1`, [gid]);
|
|
193
|
+
|
|
194
|
+
// --- 5. a later joiner does not inherit the invite --------------------------
|
|
195
|
+
await q(`UPDATE platform_identity_guild_engagements SET created_at = now() - interval '1 hour' WHERE client_id = 'r27-inv'`);
|
|
196
|
+
await q(`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly, joined_at)
|
|
197
|
+
VALUES ($1, $2, 'member', true, now())`, [gid, LATE]);
|
|
198
|
+
ok((await inboxOf(PRIVM)).includes('r27-guild|inv|invited'),
|
|
199
|
+
'a member the inviter could NOT see gets the "P invited this guild" notice (ADR 0336 D7)');
|
|
200
|
+
ok(!(await inboxOf(LATE)).includes('r27-guild|inv|invited'), 'a member who joined AFTER the invite does not see it');
|
|
201
|
+
ok((await inboxOf(LATE)).includes('r27-guild|pub|apply'), '… but sees the guild\'s apply engagement, whenever they joined');
|
|
202
|
+
ok(await engagements.engagementFor({ githubId: LATE, slug: 'r27-guild', clientId: 'r27-inv' }, { pool }) === null,
|
|
203
|
+
'and cannot apply with an invite they never saw — the join and the inbox share one rule');
|
|
204
|
+
const forPriv = await engagements.engagementFor({ githubId: PRIVM, slug: 'R27-Guild'.toLowerCase(), clientId: 'r27-inv' }, { pool });
|
|
205
|
+
ok(forPriv && String(forPriv.id) === String(gid), 'a member who saw the notice may apply with the guild');
|
|
206
|
+
|
|
207
|
+
// --- 6. the inbox drops what the caller has already done --------------------
|
|
208
|
+
await recordApplication({ githubId: MEMBER, clientId: 'r27-inv', guildId: gid }, { pool });
|
|
209
|
+
ok(!(await inboxOf(MEMBER)).some((x) => x.includes('|inv|')), 'a live application echo removes the card');
|
|
210
|
+
await q(`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at)
|
|
211
|
+
VALUES ('r27-pub', $1, 'pending', now() - interval '40 days')`, [MEMBER]);
|
|
212
|
+
ok((await inboxOf(MEMBER)).includes('r27-guild|pub|apply'), 'a LAPSED invite notice is no invite: the card stays');
|
|
213
|
+
await q(`UPDATE platform_identity_project_memberships SET joined_at = now() WHERE client_id = 'r27-pub' AND github_id = $1`, [MEMBER]);
|
|
214
|
+
ok(!(await inboxOf(MEMBER)).includes('r27-guild|pub|apply'), 'a live invite notice removes the card');
|
|
215
|
+
await q(`UPDATE platform_identity_project_memberships SET membership_kind = 'member' WHERE client_id = 'r27-pub' AND github_id = $1`, [MEMBER]);
|
|
216
|
+
ok(!(await inboxOf(MEMBER)).includes('r27-guild|pub|apply'), 'so does building on the project');
|
|
217
|
+
|
|
218
|
+
// --- 7. the label: still a member, the latest application, dissolve -----------
|
|
219
|
+
const label = await engagements.echoGuildLabel({ githubId: MEMBER, clientId: 'r27-inv' }, { pool });
|
|
220
|
+
ok(label && label.slug === 'r27-guild' && label.name === 'R27 Guild', 'the echo carries the guild while the applicant is in it');
|
|
221
|
+
await recordApplication({ githubId: PRIVM, clientId: 'r27-inv', guildId: gid }, { pool });
|
|
222
|
+
await q(`DELETE FROM platform_identity_guild_members WHERE guild_id = $1 AND github_id = $2`, [gid, PRIVM]);
|
|
223
|
+
ok(await engagements.echoGuildLabel({ githubId: PRIVM, clientId: 'r27-inv' }, { pool }) === null,
|
|
224
|
+
'an applicant who LEFT the guild carries no label');
|
|
225
|
+
await recordApplication({ githubId: MEMBER, clientId: 'r27-inv' }, { pool });
|
|
226
|
+
const solo = (await q(`SELECT guild_id FROM platform_identity_application_echoes WHERE github_id = $1 AND client_id = 'r27-inv'`, [MEMBER])).rows[0];
|
|
227
|
+
ok(solo && solo.guild_id === null, 'a later SOLO application clears the label — the most recent one decides');
|
|
228
|
+
await recordApplication({ githubId: MEMBER, clientId: 'r27-inv', guildId: gid }, { pool });
|
|
229
|
+
|
|
230
|
+
ok(await guilds.dissolveGuild({ guildId: gid, githubId: OWNER }, { pool }), 'the owner dissolves the guild');
|
|
231
|
+
ok((await q(`SELECT count(*)::int AS n FROM platform_identity_guild_engagements WHERE client_id LIKE 'r27-%'`)).rows[0].n === 0,
|
|
232
|
+
'dissolving CASCADES to every engagement (no tombstone)');
|
|
233
|
+
const echo = (await q(`SELECT guild_id FROM platform_identity_application_echoes WHERE github_id = $1 AND client_id = 'r27-inv'`, [MEMBER])).rows[0];
|
|
234
|
+
ok(echo && echo.guild_id === null, 'the application itself stands; only its label is SET NULL');
|
|
235
|
+
|
|
236
|
+
console.log(`\nguild_engagement_db.mjs: ${pass} passed`);
|
|
237
|
+
} finally {
|
|
238
|
+
await cleanup();
|
|
239
|
+
await pool.end();
|
|
240
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// tests/hub_engagement_cards.mjs — the guild cards in /projects?view=mine's
|
|
2
|
+
// Invitations, EXECUTED (task 1002303, R27; ADR 0365 D2/D3).
|
|
3
|
+
//
|
|
4
|
+
// GET /me/guild-engagements names what the guilds a builder is in have going with
|
|
5
|
+
// a project: "G is applying to P" (the guild's owner put it forward) and "P
|
|
6
|
+
// invited G". Each card's Apply is the hub's ONE join relay (postJoinRequest,
|
|
7
|
+
// tests/card_apply_ui.mjs pins it for the map's card) with `guild` added — the
|
|
8
|
+
// builder applies as themselves, never as the guild. What these pin:
|
|
9
|
+
// • the two kinds draw as two sentences, with the project's host and a plain
|
|
10
|
+
// hint; a row the page cannot act on is dropped, never drawn with a dead button
|
|
11
|
+
// • the Invitations section shows while EITHER list has a row, and hides when
|
|
12
|
+
// neither does (the projects' own invites and the guild cards are one section)
|
|
13
|
+
// • Apply sends exactly { origin, guild } — plus the builder's own note when they
|
|
14
|
+
// open "Add a note" and write one — on the cookie session, to /my-projects/join
|
|
15
|
+
// • `requested` is the only answer that reads as applied; the relay's other
|
|
16
|
+
// answers are said in its own words and the card stays re-tryable
|
|
17
|
+
// • 409 guild_engagement_gone says the guild application has ended and offers
|
|
18
|
+
// the plain application, which then sends no `guild`
|
|
19
|
+
// • an expired session and a dead network are said as such
|
|
20
|
+
// • textContent / createElement only — no markup built from strings
|
|
21
|
+
//
|
|
22
|
+
// The idiom is tests/project_invite_ui.mjs: lift the region out of projects.html,
|
|
23
|
+
// run it in a node:vm under a DOM stub, drive real clicks, and assert the slice
|
|
24
|
+
// boundaries, so a rename can never leave this file executing an empty string.
|
|
25
|
+
//
|
|
26
|
+
// DB-free. Run: node --test tests/hub_engagement_cards.mjs
|
|
27
|
+
|
|
28
|
+
import test from 'node:test';
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import vm from 'node:vm';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
37
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
39
|
+
|
|
40
|
+
// the rows region: mineEl/mineRow, the two lists, the section, and the guild cards
|
|
41
|
+
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
42
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadEngagements() {')) + 5;
|
|
43
|
+
assert.ok(START > 0, 'the region starts at mineEl');
|
|
44
|
+
assert.ok(END > START, 'and ends with loadEngagements');
|
|
45
|
+
const REGION = SCRIPT.slice(START, END);
|
|
46
|
+
for (const fn of ['function mineRow(', 'function renderInvitesList(', 'function paintInvitesSection(', 'function loadInvites(',
|
|
47
|
+
'function engageLine(', 'function engageRow(', 'function renderEngagements(', 'function loadEngagements(']) {
|
|
48
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
49
|
+
}
|
|
50
|
+
const RELAY_START = SCRIPT.indexOf('function postJoinRequest(');
|
|
51
|
+
const RELAY = SCRIPT.slice(RELAY_START, SCRIPT.indexOf('\n }', RELAY_START) + 4);
|
|
52
|
+
assert.ok(RELAY_START > 0 && RELAY.endsWith('}'), 'postJoinRequest is findable');
|
|
53
|
+
const CARDS = REGION.slice(REGION.indexOf('function paintInvitesSection('));
|
|
54
|
+
|
|
55
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
56
|
+
// Just wide enough for the region: created elements keep their children; setting
|
|
57
|
+
// textContent clears them, as in a browser; querySelector answers one class.
|
|
58
|
+
function el(tag) {
|
|
59
|
+
const n = { tag, children: [], _text: '', className: '', hidden: false, disabled: false, value: '', attrs: {}, _on: {}, parent: null };
|
|
60
|
+
Object.defineProperty(n, 'textContent', {
|
|
61
|
+
get() { return n._text + n.children.map((c) => c.textContent).join(''); },
|
|
62
|
+
set(v) { n._text = String(v); n.children = []; },
|
|
63
|
+
});
|
|
64
|
+
n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
|
|
65
|
+
n.remove = () => { if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n); };
|
|
66
|
+
n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
|
|
67
|
+
n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
|
|
68
|
+
n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
|
|
69
|
+
n.focus = () => { n.focused = true; };
|
|
70
|
+
n.querySelector = (sel) => {
|
|
71
|
+
const cls = sel.replace(/^\./, '');
|
|
72
|
+
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; };
|
|
73
|
+
return walk(n);
|
|
74
|
+
};
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TIDEPOOL = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'apply', project: { name: 'Tidepool', origin: 'https://tidepool.example.com' }, created_at: '2026-10-03T12:00:00.000Z' };
|
|
79
|
+
const LANTERN = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'invited', project: { name: 'Lantern Works', origin: 'https://lantern.example.com' }, created_at: '2026-10-02T09:30:00.000Z' };
|
|
80
|
+
const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested access. The project’s owner admits new builders.' } };
|
|
81
|
+
|
|
82
|
+
function boot({ answers = [] } = {}) {
|
|
83
|
+
const ids = new Map();
|
|
84
|
+
for (const id of ['mineInvites', 'invitesList', 'engageList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
85
|
+
ids.get('mineInvites').hidden = true;
|
|
86
|
+
const requests = [];
|
|
87
|
+
const queued = answers.slice();
|
|
88
|
+
const fetch = (url, init = {}) => {
|
|
89
|
+
requests.push({ url, method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : undefined, credentials: init.credentials });
|
|
90
|
+
const a = typeof queued[0] === 'function' ? queued.shift()(url) : (queued.shift() || REQUESTED);
|
|
91
|
+
if (a.reject) return Promise.reject(new Error('offline'));
|
|
92
|
+
return Promise.resolve({ ok: a.ok, status: a.status, json: () => Promise.resolve(a.body) });
|
|
93
|
+
};
|
|
94
|
+
const document = { createElement: el, createTextNode: (t) => ({ tag: '#text', textContent: String(t), children: [], className: '' }) };
|
|
95
|
+
const sandbox = {
|
|
96
|
+
document, fetch, console, Promise, JSON, String, Object, Array, URL,
|
|
97
|
+
$: (id) => ids.get(id) || null,
|
|
98
|
+
API: '/api/bongos',
|
|
99
|
+
appliedOrigins: {}, mineOrigins: {},
|
|
100
|
+
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
101
|
+
safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
|
|
102
|
+
jsonOrEmpty: (res) => res.json().catch(() => ({})),
|
|
103
|
+
apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '' }; },
|
|
104
|
+
};
|
|
105
|
+
vm.createContext(sandbox);
|
|
106
|
+
vm.runInContext(RELAY, sandbox, { filename: 'projects.html#join-relay' });
|
|
107
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
|
|
108
|
+
const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
|
109
|
+
const cards = () => ids.get('engageList').children;
|
|
110
|
+
const part = (card, cls) => card.querySelector('.' + cls);
|
|
111
|
+
const button = (card, cls) => part(card, 'mineRow__actions').children.find((b) => String(b.className).split(' ').includes(cls));
|
|
112
|
+
const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
|
|
113
|
+
return { sandbox, ids, requests, settle, cards, part, button, click, section: () => ids.get('mineInvites') };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── drawing the cards ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
test('the two kinds draw as two plain sentences, with the project’s host, a hint, and their own Apply', () => {
|
|
119
|
+
const ui = boot();
|
|
120
|
+
ui.sandbox.renderEngagements([TIDEPOOL, LANTERN]);
|
|
121
|
+
const [a, b] = ui.cards();
|
|
122
|
+
assert.equal(ui.cards().length, 2);
|
|
123
|
+
assert.equal(ui.part(a, 'mineRow__name').textContent, 'Forge Crew is applying to Tidepool — apply with them?');
|
|
124
|
+
assert.equal(ui.part(b, 'mineRow__name').textContent, 'Lantern Works invited Forge Crew');
|
|
125
|
+
assert.equal(ui.part(a, 'mineRow__host').textContent, 'tidepool.example.com');
|
|
126
|
+
assert.equal(ui.part(a, 'mineRow__hint').textContent, 'You apply as yourself — add a note for the owner if you like.');
|
|
127
|
+
assert.equal(a.className, 'mineRow engageRow');
|
|
128
|
+
assert.equal(ui.button(a, 'btn').textContent, 'Apply');
|
|
129
|
+
assert.equal(ui.button(a, 'linklike').textContent, 'Add a note');
|
|
130
|
+
assert.equal(ui.part(a, 'mineJoinNote').hidden, true, 'the note waits behind "Add a note"');
|
|
131
|
+
assert.equal(ui.part(a, 'mineJoinNote').maxLength, 400, 'the join note’s own limit');
|
|
132
|
+
assert.equal(ui.part(a, 'projMsg').getAttribute('role'), 'status');
|
|
133
|
+
assert.equal(ui.section().hidden, false, 'guild cards alone show the Invitations section');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a row the page cannot act on is dropped, never drawn with a button that fails', () => {
|
|
137
|
+
const ui = boot();
|
|
138
|
+
ui.sandbox.renderEngagements([
|
|
139
|
+
{ ...TIDEPOOL, kind: 'merged' },
|
|
140
|
+
{ ...TIDEPOOL, guild: { slug: null, name: 'Night Shift' } },
|
|
141
|
+
{ ...TIDEPOOL, guild: null },
|
|
142
|
+
{ ...TIDEPOOL, project: { name: 'Bad', origin: 'javascript:alert(1)' } },
|
|
143
|
+
{ ...TIDEPOOL, project: null },
|
|
144
|
+
null,
|
|
145
|
+
]);
|
|
146
|
+
assert.equal(ui.cards().length, 0);
|
|
147
|
+
assert.equal(ui.section().hidden, true, 'nothing to show: the section hides');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the section shows while EITHER list has a row, and a decline that empties both hides it', async () => {
|
|
151
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true } }] });
|
|
152
|
+
ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
|
|
153
|
+
ui.sandbox.renderEngagements([]);
|
|
154
|
+
assert.equal(ui.section().hidden, false, 'a project’s own invite alone');
|
|
155
|
+
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
156
|
+
ui.sandbox.renderInvitesList([]);
|
|
157
|
+
assert.equal(ui.section().hidden, false, 'guild cards alone');
|
|
158
|
+
ui.sandbox.renderEngagements([]);
|
|
159
|
+
assert.equal(ui.section().hidden, true, 'neither');
|
|
160
|
+
|
|
161
|
+
ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
|
|
162
|
+
const row = ui.ids.get('invitesList').children[0];
|
|
163
|
+
const decline = row.querySelector('.mineRow__actions').children.find((b) => b.textContent === 'Decline');
|
|
164
|
+
await ui.click(decline);
|
|
165
|
+
assert.equal(ui.ids.get('invitesList').children.length, 0);
|
|
166
|
+
assert.equal(ui.section().hidden, true, 'the last decline hides it when there is no guild card either');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('loadInvites reads both lists, and a guild read that fails is silent', async () => {
|
|
170
|
+
const ui = boot({ answers: [
|
|
171
|
+
(url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
|
|
172
|
+
(url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
|
|
173
|
+
] });
|
|
174
|
+
await ui.sandbox.loadInvites();
|
|
175
|
+
await ui.settle();
|
|
176
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(), ['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/my-projects/invites']);
|
|
177
|
+
for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
|
|
178
|
+
assert.equal(ui.cards().length, 0);
|
|
179
|
+
assert.equal(ui.section().hidden, true);
|
|
180
|
+
|
|
181
|
+
const ok = boot({ answers: [
|
|
182
|
+
(url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
|
|
183
|
+
(url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
|
|
184
|
+
] });
|
|
185
|
+
await ok.sandbox.loadInvites();
|
|
186
|
+
await ok.settle();
|
|
187
|
+
assert.equal(ok.cards().length, 1);
|
|
188
|
+
assert.equal(ok.section().hidden, false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── applying ─────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
test('Apply sends exactly { origin, guild } to the one join relay, on the cookie session — you apply as yourself', async () => {
|
|
194
|
+
const ui = boot();
|
|
195
|
+
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
196
|
+
await ui.click(ui.button(ui.cards()[0], 'btn'));
|
|
197
|
+
assert.equal(ui.requests.length, 1);
|
|
198
|
+
assert.equal(ui.requests[0].url, '/api/bongos/my-projects/join');
|
|
199
|
+
assert.equal(ui.requests[0].method, 'POST');
|
|
200
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
201
|
+
assert.deepEqual(ui.requests[0].body, { origin: 'https://tidepool.example.com', guild: 'forge-crew' },
|
|
202
|
+
'the project’s own origin as the read gave it, and the guild’s slug — nothing names anyone else');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('"Add a note" opens the card’s own note, and the note rides along, trimmed', async () => {
|
|
206
|
+
const ui = boot();
|
|
207
|
+
ui.sandbox.renderEngagements([TIDEPOOL, LANTERN]);
|
|
208
|
+
const card = ui.cards()[0];
|
|
209
|
+
await ui.click(ui.button(card, 'linklike'));
|
|
210
|
+
assert.equal(ui.part(card, 'mineJoinNote').hidden, false);
|
|
211
|
+
assert.equal(ui.part(card, 'mineJoinNote').focused, true, 'focus goes to the note it opened');
|
|
212
|
+
assert.equal(ui.button(card, 'linklike').hidden, true);
|
|
213
|
+
assert.equal(ui.part(ui.cards()[1], 'mineJoinNote').hidden, true, 'the other card’s note stays shut');
|
|
214
|
+
ui.part(card, 'mineJoinNote').value = ' I like the boring parts too. ';
|
|
215
|
+
await ui.click(ui.button(card, 'btn'));
|
|
216
|
+
assert.deepEqual(ui.requests[0].body, { origin: 'https://tidepool.example.com', note: 'I like the boring parts too.', guild: 'forge-crew' });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('requested is the one answer that reads as applied: the button says so and stays down, and the map learns it too', async () => {
|
|
220
|
+
const ui = boot({ answers: [REQUESTED] });
|
|
221
|
+
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
222
|
+
const card = ui.cards()[0];
|
|
223
|
+
await ui.click(ui.button(card, 'linklike'));
|
|
224
|
+
await ui.click(ui.button(card, 'btn'));
|
|
225
|
+
assert.equal(ui.button(card, 'btn').textContent, 'Applied');
|
|
226
|
+
assert.equal(ui.button(card, 'btn').disabled, true);
|
|
227
|
+
assert.equal(ui.button(card, 'btn').className, 'btn small ghost', 'no longer pressable, so no longer the accent');
|
|
228
|
+
assert.equal(ui.part(card, 'mineJoinNote').hidden, true, 'a sent note is not offered again');
|
|
229
|
+
assert.equal(ui.part(card, 'mineRow__hint').hidden, true, 'nor is the hint about one');
|
|
230
|
+
assert.equal(ui.part(card, 'projMsg').className, 'projMsg ok');
|
|
231
|
+
assert.equal(ui.part(card, 'projMsg').textContent, 'Requested access. The project’s owner admits new builders.', 'the relay’s own words');
|
|
232
|
+
assert.equal(ui.sandbox.appliedOrigins['tidepool.example.com'], true, 'the map’s Apply reads the same echo');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('409 guild_engagement_gone: the guild application has ended, the plain application is offered, and it sends no guild', async () => {
|
|
236
|
+
const ui = boot({ answers: [{ ok: false, status: 409, body: { error: { code: 'guild_engagement_gone', message: 'guild_engagement_gone' } } }, REQUESTED] });
|
|
237
|
+
ui.sandbox.renderEngagements([LANTERN]);
|
|
238
|
+
const card = ui.cards()[0];
|
|
239
|
+
await ui.click(ui.button(card, 'btn'));
|
|
240
|
+
assert.equal(ui.part(card, 'projMsg').textContent, 'That guild application has ended — you can still apply on your own.');
|
|
241
|
+
assert.equal(ui.button(card, 'btn').textContent, 'Apply on my own');
|
|
242
|
+
assert.equal(ui.button(card, 'btn').disabled, false);
|
|
243
|
+
assert.equal(ui.sandbox.appliedOrigins['lantern.example.com'], undefined, 'nothing was relayed, so nothing reads as applied');
|
|
244
|
+
|
|
245
|
+
await ui.click(ui.button(card, 'btn'));
|
|
246
|
+
assert.deepEqual(ui.requests[1].body, { origin: 'https://lantern.example.com' }, 'on your own means no guild');
|
|
247
|
+
assert.equal(ui.button(card, 'btn').textContent, 'Applied');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the relay’s other answers are said in its own words, and the card stays re-tryable', async () => {
|
|
251
|
+
const ui = boot({ answers: [
|
|
252
|
+
{ ok: true, status: 200, body: { ok: true, status: 'invite_only', message: 'This project is invite-only — it doesn’t take requests.' } },
|
|
253
|
+
{ ok: false, status: 404, body: { error: { code: 'unknown_project', message: 'no such project' } } },
|
|
254
|
+
{ ok: false, status: 500, body: {} },
|
|
255
|
+
] });
|
|
256
|
+
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
257
|
+
const card = ui.cards()[0];
|
|
258
|
+
await ui.click(ui.button(card, 'btn'));
|
|
259
|
+
assert.equal(ui.part(card, 'projMsg').className, 'projMsg', 'the door’s answer, not an error');
|
|
260
|
+
assert.match(ui.part(card, 'projMsg').textContent, /invite-only/);
|
|
261
|
+
assert.equal(ui.button(card, 'btn').textContent, 'Apply');
|
|
262
|
+
assert.equal(ui.button(card, 'btn').disabled, false);
|
|
263
|
+
await ui.click(ui.button(card, 'btn'));
|
|
264
|
+
assert.equal(ui.part(card, 'projMsg').className, 'projMsg err');
|
|
265
|
+
assert.equal(ui.part(card, 'projMsg').textContent, 'no such project');
|
|
266
|
+
await ui.click(ui.button(card, 'btn'));
|
|
267
|
+
assert.equal(ui.part(card, 'projMsg').textContent, 'Could not reach that project — try again in a moment.');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('an expired session offers a way back in, built from elements; a dead network is said as one', async () => {
|
|
271
|
+
const ui = boot({ answers: [{ ok: false, status: 401, body: null }, { reject: true }] });
|
|
272
|
+
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
273
|
+
const card = ui.cards()[0];
|
|
274
|
+
await ui.click(ui.button(card, 'btn'));
|
|
275
|
+
const msg = ui.part(card, 'projMsg');
|
|
276
|
+
assert.equal(msg.className, 'projMsg err');
|
|
277
|
+
assert.equal(msg.textContent, 'Your session expired — sign in again.');
|
|
278
|
+
const link = msg.children.find((c) => c.tag === 'a');
|
|
279
|
+
assert.equal(link.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1', 'back to this view, with the repo scope every hub sign-in carries');
|
|
280
|
+
assert.equal(ui.button(card, 'btn').disabled, false);
|
|
281
|
+
await ui.click(ui.button(card, 'btn'));
|
|
282
|
+
assert.equal(msg.textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ── the markup, the region's rules, the kit ──────────────────────────────────
|
|
286
|
+
|
|
287
|
+
test('the cards are a real list inside the Invitations section, built with createElement / textContent only', () => {
|
|
288
|
+
const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
289
|
+
assert.match(section, /<ul class="mineRows" id="invitesList"><\/ul>/);
|
|
290
|
+
assert.match(section, /<ul class="mineRows" id="engageList"><\/ul>/, 'ships as real markup the script only fills');
|
|
291
|
+
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
|
|
292
|
+
assert.match(SCRIPT, /if \(v === 'mine' && me\) \{ refreshList\(\); loadMemberships\(\); loadInvites\(\); \}/, 'the mine view re-reads both lists on entry, through loadInvites');
|
|
293
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
294
|
+
assert.match(COSMOS, /\.mineRows:not\(:empty\)\+\.mineRows \.mineRow:first-child\{border-top:1px solid var\(--rule-soft\);\}/, 'one hairline between the two lists when both have rows');
|
|
295
|
+
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('the kit pictures the cards and the stub answers the read and the guild on the join', () => {
|
|
299
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
300
|
+
assert.ok(states['in-mine-engagements'] && states['in-mine-engagement-applied'], 'two kit states: the cards, and one applied');
|
|
301
|
+
const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
302
|
+
assert.match(serve, /'\/me\/guild-engagements': 'me__guild-engagements'/);
|
|
303
|
+
assert.match(serve, /refuse\(res, 409, 'guild_engagement_gone'/, 'the stub refuses a guild with no engagement, as the route does');
|
|
304
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'me__guild-engagements.json'), 'utf8'));
|
|
305
|
+
assert.deepEqual(fixture.engagements.map((e) => e.kind).sort(), ['apply', 'invited']);
|
|
306
|
+
for (const e of fixture.engagements) assert.deepEqual(Object.keys(e).sort(), ['created_at', 'guild', 'kind', 'project'], 'the route’s row shape, and never a client_id');
|
|
307
|
+
});
|