From 8b63ffc725405c8e6525894bfb2a27af40a5c6f5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 14:44:45 -0500 Subject: [PATCH] feat(modules): registerTeamProvider, and a call path that cannot answer "empty" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registration a module uses to become the authoritative source of Teams (docs/website/TEAMS.md §2.3), plus the wrapper core calls it through. registerTeamProvider is the first registration where core CALLS THE MODULE and waits for an answer. Every existing one is either the module claiming a mount or core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and this is modelled on it rather than invented. It also holds a single value rather than a map, unlike every other registry: Teams have one authoritative source by construction, and two modules answering "what teams exist" would produce two disjoint sets under one `teams` table with no rule for merging them. A second registration is therefore a collision, named against the module that holds it. teamProvider.js is where invariant 1 -- module unavailability is staleness, never emptiness -- is actually enforced. It is deliberately generous about what counts as a failure: a rejected promise, a synchronous throw, a timeout, a non-object, a bare array, a missing `ok`, or a structurally malformed row all leave as the same `{ ok: false }` a module would have sent on purpose. There is no shape a broken provider can produce that arrives at the reconciler looking like an authoritative empty list -- which is the entire argument for the envelope, since a bare array has exactly one such shape and it is the one a module returns while its sidecar is still connecting. A malformed row fails the whole call rather than being dropped. Salvaging is the dangerous option: one unreadable member quietly omitted from a roster is indistinguishable, downstream, from that member having left, and the sync would mark them departed on the strength of a broken payload. Refusing costs one stale interval. The deadline timer is unreffed as well as cleared. Clearing covers the case where the race settles; it cannot cover a module promise that never settles at all, where nothing exists to clear until the deadline fires. Caught by the test file taking 10.2s to run 265ms of assertions -- the same class of bug as the mariadb pool that used to hold the suite open (test/_setup.js). 292ms now. 28 tests. Full suite 770 passed, 0 failed. Refs docs/website/TEAMS.md §2.3, Part 12 phase 2 Co-Authored-By: Claude --- server/src/model/teams/teamProvider.js | 182 ++++++++++++++++ server/src/modules/loader.js | 9 + server/src/modules/registries.js | 58 ++++- server/test/teamProvider.test.js | 289 +++++++++++++++++++++++++ 4 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 server/src/model/teams/teamProvider.js create mode 100644 server/test/teamProvider.test.js diff --git a/server/src/model/teams/teamProvider.js b/server/src/model/teams/teamProvider.js new file mode 100644 index 0000000..945f983 --- /dev/null +++ b/server/src/model/teams/teamProvider.js @@ -0,0 +1,182 @@ +// ── Calling the Team provider ────────────────────────────────────────────── +// +// The one place core asks a module a question and waits for the answer +// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1: +// +// **Module unavailability is staleness, never emptiness.** +// +// No Team subsystem may apply a destructive result derived from a failed, +// timed-out or unanswered module call. This file is where "failed" is defined, and +// it is deliberately generous about what counts: a rejected promise, a timeout, a +// non-object, a missing `ok`, or a structurally malformed row all leave with the +// same `{ ok: false }` the module would have sent deliberately. +// +// **There is no shape a failure can take that core reads as "zero teams".** That +// is the whole argument for the envelope, and the reason the provider signature is +// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape, +// `[]`, and it is the one a module returns while its sidecar is still connecting. +// +// Nothing here touches the database. It calls the module and hands back a value +// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question. + +const registries = require('../../modules/registries') +const log = require('../../utils/logger')('teams') + +// The budget from §2.3. A provider is answering from its own cache or its own +// sidecar client, both of which have their own timeouts well inside this; a call +// that reaches ten seconds is wedged, not slow. +const CALL_TIMEOUT_MS = 10_000 + +/** A uniform refusal. `reason` is for the operator, via team_sync_state. */ +const fail = (reason) => ({ ok: false, reason }) + +/** + * Await `promise` with a timeout that cannot outlive the call. + * + * The timer is always cleared — including on the winning path — because an + * uncleared 10s timer holds the event loop open, which in a test run means the + * process hangs long after the assertions passed. The suite already learned this + * one from a mariadb pool (test/_setup.js). + * + * It is also `unref`ed, which covers the case clearing cannot: when the module's + * promise NEVER settles, the race stays pending and there is nothing to clear + * until the deadline fires. An unreffed timer still fires normally while the + * process is alive — the server's own listener is what keeps it alive — but it no + * longer holds a shutdown open for ten seconds waiting on a module that is not + * going to answer. + */ +function withTimeout(promise, ms) { + let timer + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms) + if (typeof timer.unref === 'function') timer.unref() + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +/** + * Call one provider method and normalise whatever comes back into an envelope. + * + * `normalise` is only ever run on an `ok` answer, and may itself return a refusal + * — a structurally malformed row is treated as a failed call rather than as data + * to salvage. Salvaging is the dangerous option: dropping one unreadable member + * from a roster is indistinguishable, downstream, from that member having left, + * and the sync would mark them departed. Refusing costs one stale interval. + */ +async function call(method, normalise, ...args) { + const provider = registries.registeredTeamProvider() + if (!provider) return fail('no team provider is registered') + + let answer + try { + answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS) + } catch (err) { + // A rejected promise is a module that threw, which is exactly as + // unauthoritative as one that answered `{ ok: false }`. + return fail(`${method}() threw: ${err.message}`) + } + + if (!answer || typeof answer !== 'object' || Array.isArray(answer)) { + return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`) + } + // `ok` must be present and true. A module that forgot the field is not one + // asserting authority, and reading a missing field as truthy would put the + // single most consequential decision in this file on a typo. + if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`) + + const normalised = normalise(answer) + if (normalised.ok === false) { + log.warn('team provider answered with a malformed payload', { + owner: provider.owner, method, reason: normalised.reason, + }) + } + return normalised +} + +// `complete` defaults to TRUE when the module omits it, matching §2.3: the +// envelope's optional field marks a partial answer, so its absence is the +// ordinary authoritative case. A module that cannot enumerate exhaustively says +// so explicitly. +const isComplete = (answer) => answer.complete !== false + +const str = (v) => (typeof v === 'string' ? v.trim() : '') + +/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */ +function normaliseTeams(answer) { + if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array') + const teams = [] + for (const raw of answer.teams) { + const externalId = str(raw && raw.externalId) + const name = str(raw && raw.name) + // Both are load-bearing and neither has a safe default: externalId is the + // identity the whole rename rule (§2.2) turns on, and a Team with no name has + // no slug and no page. + if (!externalId) return fail('a team in getTeams() has no externalId') + if (!name) return fail(`team "${externalId}" has no name`) + teams.push({ + externalId, + name, + abbr: str(raw.abbr) || null, + // Opaque by contract (§10.5) — stored and handed back, never branched on. + meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null, + }) + } + return { ok: true, complete: isComplete(answer), teams } +} + +/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */ +function normaliseMembers(answer) { + if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array') + const members = [] + const seen = new Set() + for (const raw of answer.members) { + const memberKey = str(raw && raw.memberKey) + if (!memberKey) return fail('a member has no memberKey') + // A duplicate key would upsert twice and inflate no count but confuse every + // reader; it also means the module's own identity rule is broken, which is + // worth surfacing rather than quietly collapsing. + if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`) + seen.add(memberKey) + members.push({ + memberKey, + displayName: str(raw.displayName) || null, + rankLabel: str(raw.rankLabel) || null, + leader: Boolean(raw.leader), + online: Boolean(raw.online), + // Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core + // takes the number and never looks it up. + userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null, + }) + } + return { ok: true, complete: isComplete(answer), members } +} + +/** `{ ok, leaders: [memberKey] }` */ +function normaliseLeaders(answer) { + if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array') + const leaders = [] + for (const raw of answer.leaders) { + const key = str(raw) + if (!key) return fail('a leader entry is not a member key') + if (!leaders.includes(key)) leaders.push(key) + } + return { ok: true, leaders } +} + +const getTeams = () => call('getTeams', normaliseTeams) +const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId) +const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId) + +/** Which module is authoritative, or null. The reconciler keys sync state on it. */ +const providerModuleId = () => { + const provider = registries.registeredTeamProvider() + return provider ? provider.owner : null +} + +module.exports = { + getTeams, + getTeamMembers, + getTeamLeaders, + providerModuleId, + CALL_TIMEOUT_MS, +} diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 53c7aad..51faad2 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -240,6 +240,15 @@ function buildApi(record) { record.staged.registerNotificationStreams(streams) }, registerAnnounceLeg: record.staged.registerAnnounceLeg, + // The Team provider (API 1.6.0, TEAMS.md §2.3). Unlike every registration + // above, this one is core CALLING THE MODULE and waiting for an answer — the + // same direction registerAnnounceLeg's dispatch already goes, which is why it + // is modelled on it rather than invented. `once` because a module registering + // twice means two answers to a question that has one. + registerTeamProvider(provider) { + once('registerTeamProvider') + record.staged.registerTeamProvider(provider) + }, // The two lifecycle hooks (§2.5). Registered here, dispatched from // lifecycle.js — this file runs with no database and the hooks run with one. // Both are optional: a module with no warm-up and nothing to close simply diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 938765f..7165f8e 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -58,6 +58,16 @@ const legs = new Map() // a collision with a name attached rather than a silently doubled side effect. const postHooks = new Map() +// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider +// (API 1.6.0, TEAMS.md §2.3). +// +// A SINGLE value rather than a Map, unlike every registry above it, and that is +// the contract: one provider per deployment. Teams have one authoritative source +// by construction — two modules answering "what teams exist" would produce two +// disjoint sets under one `teams` table with no rule for merging them, so a +// second registration is a collision rather than an addition. +let teamProvider = null + let coreRegistered = false // Stream ids that predate the module system and may not carry their owner's @@ -196,6 +206,14 @@ const announceLegIds = () => [...legs.keys()] /** One leg, or null. */ const announceLeg = (leg) => legs.get(leg) || null +// ── Team provider (TEAMS.md §2.3) ────────────────────────────────────────── + +/** The registered provider, or null when no module supplies one. */ +const registeredTeamProvider = () => teamProvider + +/** Is there a Team provider at all? Read by the reconciler and the read API. */ +const hasTeamProvider = () => teamProvider !== null + // ── Shape checks, run the moment a registrant calls ──────────────────────── // // Split from the collision checks below on the same line PR 3 drew through @@ -225,6 +243,23 @@ function checkLegShape(entry) { return { leg, label: label || leg, dispatch, classify } } +// All three methods are REQUIRED, with no optional half. A provider that could +// list Teams but not their members would leave core holding Teams it can never +// populate, and the reconciler has no sensible behaviour for that — it is not the +// same as a call that fails, which is staleness and already handled (§2.4). A +// module unable to answer one of the three answers `{ ok: false }` at call time. +function checkTeamProviderShape(entry) { + const provider = entry || {} + const out = {} + for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) { + if (typeof provider[name] !== 'function') { + throw new Error(`registerTeamProvider: ${name}() is missing or not a function`) + } + out[name] = provider[name] + } + return out +} + /** * `registerPostHook({ onSaved, onDeleted })` — both optional, at least one * required. A registration with neither is a subscription that can never fire, @@ -265,7 +300,7 @@ function checkExtensionShape(slot, router, specFile) { * `allStreams()` / `announceLeg()` / the slot routers until `apply()`. */ function stage(owner) { - const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] } + const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] } return { staged, registerNotificationStreams(entries) { @@ -281,6 +316,9 @@ function stage(owner) { registerPostHook(entry) { staged.postHooks.push(checkPostHookShape(entry)) }, + registerTeamProvider(entry) { + staged.teamProviders.push(checkTeamProviderShape(entry)) + }, } } @@ -293,7 +331,14 @@ function stage(owner) { * PR 2 learned to protect (mounting inside the scan loop made every collision * look like it was with core). */ -function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) { +function apply({ + owner, + streams: newStreams, + legs: newLegs, + extensions: newExtensions, + postHooks: newPostHooks = [], + teamProviders: newTeamProviders = [], +}) { // ── validate ── const seenStreams = new Set() for (const s of newStreams) { @@ -332,6 +377,11 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten throw new Error(`"${owner}" already registered a post hook`) } + if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`) + if (newTeamProviders.length && teamProvider) { + throw new Error(`a team provider is already registered by "${teamProvider.owner}"`) + } + // ── commit — nothing below can fail ── for (const s of newStreams) { streamOwners.set(s.id, owner) @@ -345,6 +395,7 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten entry.router.use(x.router) } for (const h of newPostHooks) postHooks.set(owner, h) + for (const p of newTeamProviders) teamProvider = { owner, ...p } } // ── Core's own registrations ─────────────────────────────────────────────── @@ -410,6 +461,7 @@ function _reset() { streamOwners.clear() legs.clear() postHooks.clear() + teamProvider = null coreRegistered = false } @@ -427,6 +479,8 @@ module.exports = { announceLeg, postHookEntries, dispatchPostHook, + registeredTeamProvider, + hasTeamProvider, stage, apply, registerCore, diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js new file mode 100644 index 0000000..75bdd5b --- /dev/null +++ b/server/test/teamProvider.test.js @@ -0,0 +1,289 @@ +// 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) +})