// The Team provider registration and the guarded call path // (docs/website/TEAMS.md §2.3). // // Almost every test here is invariant 1 asked a different way: **module // unavailability is staleness, never emptiness.** The value of this file is that // it enumerates the shapes a broken provider can produce and asserts that none of // them arrives at the reconciler looking like authoritative data. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const teamProvider = require('../src/model/teams/teamProvider') // Register a provider the way a module does: stage, then commit. function register(owner, provider) { const api = registries.stage(owner) api.registerTeamProvider(provider) registries.apply(api.staged) } const ok = () => ({ getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'The Silver Hand' }] }), getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1', displayName: 'Aldric' }] }), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), }) beforeEach(() => registries._reset()) afterEach(() => registries._reset()) // ── Registration ─────────────────────────────────────────────────────────── test('a provider is readable only after apply(), not at stage time', () => { const api = registries.stage('uo') api.registerTeamProvider(ok()) assert.equal(registries.hasTeamProvider(), false, 'staging must not publish') registries.apply(api.staged) assert.equal(registries.hasTeamProvider(), true) assert.equal(registries.registeredTeamProvider().owner, 'uo') }) test('all three methods are required', () => { const api = registries.stage('uo') for (const missing of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) { const provider = ok() delete provider[missing] assert.throws(() => api.registerTeamProvider(provider), new RegExp(`${missing}\\(\\) is missing`)) } // A non-function is the same failure, and is the likelier typo. assert.throws(() => api.registerTeamProvider({ ...ok(), getTeams: 'yes' }), /getTeams\(\) is missing or not a function/) }) test('a second provider is a collision naming the module that holds it', () => { register('uo', ok()) const second = registries.stage('other') second.registerTeamProvider(ok()) assert.throws(() => registries.apply(second.staged), /already registered by "uo"/) // The first registration is untouched by the rejected second. assert.equal(registries.registeredTeamProvider().owner, 'uo') }) test('one module registering twice in one batch is rejected', () => { const api = registries.stage('uo') api.registerTeamProvider(ok()) api.registerTeamProvider(ok()) assert.throws(() => registries.apply(api.staged), /more than one team provider/) assert.equal(registries.hasTeamProvider(), false, 'the whole batch is refused') }) test('a rejected batch leaves no provider behind, even when its other claims are fine', () => { const api = registries.stage('uo') api.registerTeamProvider(ok()) api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }]) api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }]) // duplicate assert.throws(() => registries.apply(api.staged)) assert.equal(registries.hasTeamProvider(), false, 'validate-then-commit covers the provider too') }) // ── The call path: every failure shape becomes { ok: false } ─────────────── test('no registered provider is a refusal, not an empty answer', async () => { const answer = await teamProvider.getTeams() assert.equal(answer.ok, false) assert.equal(answer.teams, undefined, 'a refusal never carries a teams array') assert.equal(teamProvider.providerModuleId(), null) }) test('a provider that throws is a refusal', async () => { register('uo', { ...ok(), getTeams: async () => { throw new Error('sidecar unreachable') } }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, false) assert.match(answer.reason, /sidecar unreachable/) assert.equal(answer.teams, undefined) }) test('a provider that throws SYNCHRONOUSLY is a refusal too', async () => { register('uo', { ...ok(), getTeams: () => { throw new Error('boom') } }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, false) assert.match(answer.reason, /boom/) }) test('a deliberate { ok: false } keeps its reason for team_sync_state', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: false, reason: 'cache cold' }) }) assert.deepEqual(await teamProvider.getTeams(), { ok: false, reason: 'cache cold' }) }) test('a missing ok field is not read as authority', async () => { register('uo', { ...ok(), getTeams: async () => ({ teams: [] }) }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, false, 'a forgotten field must not become an authoritative empty list') }) test('a bare array — the shape the envelope exists to outlaw — is a refusal', async () => { register('uo', { ...ok(), getTeams: async () => [] }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, false) assert.match(answer.reason, /not an envelope/) }) test('null, undefined and a string are all refusals', async () => { for (const bad of [null, undefined, 'ok', 42]) { register('uo', { ...ok(), getTeams: async () => bad }) // eslint-disable-next-line no-await-in-loop assert.equal((await teamProvider.getTeams()).ok, false, `${String(bad)} must not be authoritative`) registries._reset() } }) test('ok:true with no teams array is a refusal, not zero teams', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: true }) }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, false) assert.match(answer.reason, /no teams array/) }) test('an ok answer with a genuinely empty list stays ok — §2.4 decides what to do with it', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [] }) }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, true, 'this file must not second-guess an authoritative empty answer') assert.deepEqual(answer.teams, []) }) // ── Malformed rows fail the call rather than being salvaged ──────────────── test('a team with no externalId fails the whole call', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ name: 'Nameless' }] }) }) const answer = await teamProvider.getTeams() assert.equal(answer.ok, false) assert.match(answer.reason, /no externalId/) }) test('a team with no name fails the whole call', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: ' ' }] }) }) assert.match((await teamProvider.getTeams()).reason, /"g1" has no name/) }) test('one unreadable member refuses the roster rather than dropping the member', async () => { // Dropping it would be indistinguishable, downstream, from the member leaving — // the sync would mark them departed on the strength of a malformed payload. register('uo', { ...ok(), getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { displayName: 'ghost' }] }), }) const answer = await teamProvider.getTeamMembers('g1') assert.equal(answer.ok, false) assert.equal(answer.members, undefined) }) test('a duplicated memberKey is refused rather than collapsed', async () => { register('uo', { ...ok(), getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { memberKey: '0x1' }] }), }) assert.match((await teamProvider.getTeamMembers('g1')).reason, /appears twice/) }) // ── Normalisation of a good answer ───────────────────────────────────────── test('team fields are trimmed, and meta is passed through opaquely', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ externalId: ' g1 ', name: ' The Silver Hand ', abbr: ' TSH ', meta: { crest: 7 } }], }), }) const { teams } = await teamProvider.getTeams() assert.deepEqual(teams, [{ externalId: 'g1', name: 'The Silver Hand', abbr: 'TSH', meta: { crest: 7 } }]) }) test('a non-object meta is dropped rather than stored as a scalar', async () => { register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'X', meta: 'crest' }] }), }) assert.equal((await teamProvider.getTeams()).teams[0].meta, null) }) test('member booleans are coerced and userId is accepted only as a positive integer', async () => { register('uo', { ...ok(), getTeamMembers: async () => ({ ok: true, members: [ { memberKey: '0x1', displayName: 'Aldric', rankLabel: 'Warlord', leader: 1, online: 'yes', userId: 7 }, { memberKey: '0x2', userId: 0 }, { memberKey: '0x3', userId: '7' }, { memberKey: '0x4', userId: 1.5 }, ], }), }) const { members } = await teamProvider.getTeamMembers('g1') assert.equal(members[0].leader, true) assert.equal(members[0].online, true) assert.equal(members[0].userId, 7) assert.equal(members[1].userId, null, '0 is not a user id') assert.equal(members[2].userId, null, 'a numeric string is not a resolved link') assert.equal(members[3].userId, null) // Absent optional fields become null rather than undefined, so a column write // does not depend on the module having spelled the key. assert.equal(members[1].displayName, null) assert.equal(members[1].rankLabel, null) }) test('complete defaults to true and is honoured when false', async () => { register('uo', ok()) assert.equal((await teamProvider.getTeams()).complete, true) registries._reset() register('uo', { ...ok(), getTeams: async () => ({ ok: true, complete: false, teams: [] }) }) assert.equal((await teamProvider.getTeams()).complete, false) }) test('duplicate leaders are collapsed and blanks refused', async () => { register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', '0x1', ' 0x2 '] }) }) assert.deepEqual((await teamProvider.getTeamLeaders('g1')).leaders, ['0x1', '0x2']) registries._reset() register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', ''] }) }) assert.equal((await teamProvider.getTeamLeaders('g1')).ok, false) }) test('the external id is passed through to the module unchanged', async () => { const seen = [] register('uo', { ...ok(), getTeamMembers: async (id) => { seen.push(id); return { ok: true, members: [] } } }) await teamProvider.getTeamMembers('g-42') assert.deepEqual(seen, ['g-42']) }) test('providerModuleId names the registrant, which is what sync state is keyed on', async () => { register('uo', ok()) assert.equal(teamProvider.providerModuleId(), 'uo') }) // ── The timeout ──────────────────────────────────────────────────────────── test('a provider that never answers becomes a refusal at the deadline', async (t) => { // Mocked timers rather than a real ten-second wait: this exercises the // production path exactly — the same setTimeout, the same deadline — without // putting ten seconds into every CI run. t.mock.timers.enable({ apis: ['setTimeout'] }) register('uo', { ...ok(), getTeams: () => new Promise(() => {}) }) const pending = teamProvider.getTeams() t.mock.timers.tick(teamProvider.CALL_TIMEOUT_MS) const answer = await pending assert.equal(answer.ok, false) assert.match(answer.reason, /did not answer within 10000ms/) assert.equal(answer.teams, undefined, 'a hung module never produces data') }) test('a hung call does not hold the process open until its deadline', async () => { // The timer is unreffed, so a call left pending at shutdown cannot keep the // event loop alive. Asserted directly, because the symptom — a test FILE that // passes in milliseconds and then sits for ten seconds — is invisible in a // green summary. register('uo', { ...ok(), getTeams: () => new Promise(() => {}) }) const before = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length teamProvider.getTeams() await new Promise((resolve) => { setImmediate(resolve) }) const after = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length assert.equal(after, before, 'the deadline timer must not count as an active resource') }) test('the budget is the documented ten seconds', () => { assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000) }) // ── projectRoster: the optional fourth member (§3.3) ─────────────────────── // // The one Team call where a refusal must NOT be treated as staleness. Every test // below exists because the obvious implementation — reuse `call()` and serve the // roster when it fails — silently publishes the rows the rungs exist to withhold. const rows = [{ member_key: '0x1' }, { member_key: '0x2' }] test('projectRoster is optional: a provider without it registers fine', () => { const api = registries.stage('uo') assert.doesNotThrow(() => api.registerTeamProvider(ok())) }) test('a non-function projectRoster is rejected at registration, not at call time', () => { const api = registries.stage('uo') assert.throws( () => api.registerTeamProvider({ ...ok(), projectRoster: 'yes please' }), /projectRoster must be a function/, ) }) // ── pageUrlTemplate: the optional fifth member (§6.4) ────────────────────── // // Data, not a method, and the only thing core can use to link to a Team page — // Teams have no core surface, so the module that owns the page has to say where // it is. Validated hard because the output goes into an email as a link. test('pageUrlTemplate is optional: a provider without it registers fine', () => { const api = registries.stage('uo') assert.doesNotThrow(() => api.registerTeamProvider(ok())) }) test('a relative template is kept exactly as given', () => { register('uo', { ...ok(), pageUrlTemplate: '/uo/guilds/{externalId}' }) assert.equal(registries.registeredTeamProvider().pageUrlTemplate, '/uo/guilds/{externalId}') }) test('an absolute template is refused — a module may not redirect the site’s mail', () => { const api = registries.stage('uo') for (const bad of [ 'https://evil.test/{externalId}', '//evil.test/x', 'uo/guilds/{externalId}', // not rooted '/uo/guilds/{externalId}?x=