feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls #151

Merged
whitlocktech merged 6 commits from feat/teams-phase2-team-core into edge 2026-08-17 22:20:34 +00:00
4 changed files with 536 additions and 2 deletions
Showing only changes of commit 8b63ffc725 - Show all commits

View File

@@ -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,
}

View File

@@ -240,6 +240,15 @@ function buildApi(record) {
record.staged.registerNotificationStreams(streams) record.staged.registerNotificationStreams(streams)
}, },
registerAnnounceLeg: record.staged.registerAnnounceLeg, 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 // The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one. // 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 // Both are optional: a module with no warm-up and nothing to close simply

View File

@@ -58,6 +58,16 @@ const legs = new Map()
// a collision with a name attached rather than a silently doubled side effect. // a collision with a name attached rather than a silently doubled side effect.
const postHooks = new Map() 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 let coreRegistered = false
// Stream ids that predate the module system and may not carry their owner's // 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. */ /** One leg, or null. */
const announceLeg = (leg) => legs.get(leg) || 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 ──────────────────────── // ── Shape checks, run the moment a registrant calls ────────────────────────
// //
// Split from the collision checks below on the same line PR 3 drew through // 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 } 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 * `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
* required. A registration with neither is a subscription that can never fire, * 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()`. * `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
*/ */
function stage(owner) { function stage(owner) {
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] } const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
return { return {
staged, staged,
registerNotificationStreams(entries) { registerNotificationStreams(entries) {
@@ -281,6 +316,9 @@ function stage(owner) {
registerPostHook(entry) { registerPostHook(entry) {
staged.postHooks.push(checkPostHookShape(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 * PR 2 learned to protect (mounting inside the scan loop made every collision
* look like it was with core). * 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 ── // ── validate ──
const seenStreams = new Set() const seenStreams = new Set()
for (const s of newStreams) { 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`) 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 ── // ── commit — nothing below can fail ──
for (const s of newStreams) { for (const s of newStreams) {
streamOwners.set(s.id, owner) streamOwners.set(s.id, owner)
@@ -345,6 +395,7 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
entry.router.use(x.router) entry.router.use(x.router)
} }
for (const h of newPostHooks) postHooks.set(owner, h) for (const h of newPostHooks) postHooks.set(owner, h)
for (const p of newTeamProviders) teamProvider = { owner, ...p }
} }
// ── Core's own registrations ─────────────────────────────────────────────── // ── Core's own registrations ───────────────────────────────────────────────
@@ -410,6 +461,7 @@ function _reset() {
streamOwners.clear() streamOwners.clear()
legs.clear() legs.clear()
postHooks.clear() postHooks.clear()
teamProvider = null
coreRegistered = false coreRegistered = false
} }
@@ -427,6 +479,8 @@ module.exports = {
announceLeg, announceLeg,
postHookEntries, postHookEntries,
dispatchPostHook, dispatchPostHook,
registeredTeamProvider,
hasTeamProvider,
stage, stage,
apply, apply,
registerCore, registerCore,

View File

@@ -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)
})