Files
website/server/test/teamSync.test.js
wtclaude 92631347f9 feat(teams): the reconciler, its four refusal gates, and ctx.teams (API 1.6.0)
Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.

The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:

  1. getTeams() not ok           -> record the failure, touch NOTHING, return.
  2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
                                    authoritative answer, an interval later,
                                    agrees.
  3. getTeamMembers() not ok      -> that Team's roster untouched and stale; the
                                    other Teams sync normally.
  4. ok but zero members, had some -> the same two-strikes quarantine, per Team.

Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
start is the one failure indistinguishable from a real wipe. "Every Team on the
shard disbanded at once" costs one interval to confirm; getting it wrong empties
every roster on the site.

Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.

Two columns TEAMS.md did not contemplate, both on `teams`:

  - roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
    leaves ONE Team behind while the others sync. Without a per-Team stamp that
    Team's page would report the module's last success as its own -- exactly the
    staleness the gate exists to surface.

  - members_empty_since, gate 4's per-Team quarantine. The twin of
    team_sync_state.pending_empty_since, which is per module and cannot express it.

One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.

MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.

39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.

Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 14:53:52 -05:00

740 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The reconciler and its four refusal gates (docs/website/TEAMS.md §2.4).
//
// The db layer is stubbed and an in-memory projection stands in for the tables,
// so these are assertions about the ALGORITHM: which answers are applied, which
// are refused, and what is left untouched when one is refused. The gates are the
// reason the file exists — every one of them is invariant 1 in a different
// costume, and each is easy to "simplify" away by someone who has not seen what
// an empty answer during a cold start does to a site full of rosters.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db')
const settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model')
// ── An in-memory stand-in for the four tables ──────────────────────────────
let store
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
function freshStore() {
return {
teams: [], // { id, module_id, external_id, name, abbr, slug, status, ... }
members: new Map(), // teamId -> Map(memberKey -> row)
sync: new Map(), // moduleId -> row
nextId: 1,
}
}
function membersOf(teamId) {
if (!store.members.has(teamId)) store.members.set(teamId, new Map())
return store.members.get(teamId)
}
function stubDb() {
patch(teamsDb, 'activeByModule', async (moduleId) =>
store.teams.filter((t) => t.module_id === moduleId && t.status === 'active'))
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
patch(teamsDb, 'findById', async (id) => store.teams.find((t) => t.id === id))
patch(teamsDb, 'slugsLike', async (base) =>
store.teams.filter((t) => t.slug === base || t.slug.startsWith(`${base}-`)).map((t) => t.slug))
patch(teamsDb, 'insertTeam', async (row) => {
const id = store.nextId++
store.teams.push({
id,
module_id: row.moduleId,
external_id: row.externalId,
name: row.name,
abbr: row.abbr ?? null,
slug: row.slug,
meta: row.meta ?? null,
status: 'active',
hidden: row.hidden ? 1 : 0,
hidden_reason: row.hiddenReason || null,
hidden_term: row.hiddenTerm || null,
members_empty_since: null,
roster_synced_at: null,
succeeded_by: null,
member_count: 0,
linked_count: 0,
online_count: 0,
})
return id
})
patch(teamsDb, 'updateTeam', async (id, { abbr, meta }) => {
const t = store.teams.find((x) => x.id === id)
if (t) Object.assign(t, { abbr, meta })
})
patch(teamsDb, 'archiveTeam', async (id, reason, succeededBy = null) => {
const t = store.teams.find((x) => x.id === id && x.status === 'active')
if (t) Object.assign(t, { status: 'archived', archived_reason: reason, succeeded_by: succeededBy })
})
patch(teamsDb, 'recount', async (teamId) => {
const t = store.teams.find((x) => x.id === teamId)
if (!t) return
const rows = [...membersOf(teamId).values()].filter((m) => m.status === 'active')
t.member_count = rows.length
t.linked_count = rows.filter((m) => m.user_id != null).length
t.online_count = rows.filter((m) => m.online).length
})
patch(teamsDb, 'markRosterSynced', async (teamId) => {
const t = store.teams.find((x) => x.id === teamId)
if (t) t.roster_synced_at = new Date()
})
patch(teamsDb, 'setMembersEmptySince', async (teamId, since) => {
const t = store.teams.find((x) => x.id === teamId)
if (t) t.members_empty_since = since
})
patch(teamsDb, 'memberKeys', async (teamId) =>
[...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key))
patch(teamsDb, 'upsertMember', async (m) => {
const existing = membersOf(m.teamId).get(m.memberKey)
membersOf(m.teamId).set(m.memberKey, {
team_id: m.teamId,
member_key: m.memberKey,
display_name: m.displayName ?? null,
user_id: m.userId ?? null,
// Insert-only, mirroring the ON DUPLICATE KEY UPDATE clause that omits it:
// leadership is getTeamLeaders()'s answer, not the roster's.
is_leader: existing ? existing.is_leader : (m.isLeader ? 1 : 0),
rank_label: m.rankLabel ?? null,
online: m.online ? 1 : 0,
status: 'active',
first_seen_at: existing ? existing.first_seen_at : 'first',
})
})
patch(teamsDb, 'markDeparted', async (teamId, keys) => {
for (const key of keys) {
const row = membersOf(teamId).get(key)
if (row && row.status === 'active') Object.assign(row, { status: 'departed', online: 0 })
}
})
patch(teamsDb, 'setLeaders', async (teamId, leaderKeys) => {
for (const row of membersOf(teamId).values()) row.is_leader = leaderKeys.includes(row.member_key) ? 1 : 0
})
patch(teamsDb, 'setMemberLeader', async (teamId, key, isLeader) => {
const row = membersOf(teamId).get(key)
if (row) row.is_leader = isLeader ? 1 : 0
})
patch(teamsDb, 'syncState', async (moduleId) => store.sync.get(moduleId))
patch(teamsDb, 'recordAttempt', async (moduleId) => {
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
s.last_attempt_at = new Date()
store.sync.set(moduleId, s)
})
patch(teamsDb, 'recordFailure', async (moduleId, error) => {
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
s.consecutive_failures += 1
s.last_error = error
store.sync.set(moduleId, s)
})
patch(teamsDb, 'recordSuccess', async (moduleId) => {
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
s.consecutive_failures = 0
s.last_error = null
s.last_success_at = new Date()
store.sync.set(moduleId, s)
})
patch(teamsDb, 'setPendingEmpty', async (moduleId, since) => {
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
s.pending_empty_since = since
store.sync.set(moduleId, s)
})
}
// A provider whose answers the test controls. Defaults are authoritative and
// well-formed, so each test only states the part it is about.
function provide(overrides = {}) {
const provider = {
getTeams: async () => ({ ok: true, teams: [] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
...overrides,
}
const api = registries.stage('uo')
api.registerTeamProvider(provider)
registries.apply(api.staged)
return provider
}
const team = (externalId, name, extra = {}) => ({ externalId, name, abbr: null, meta: null, ...extra })
const member = (memberKey, extra = {}) => ({
memberKey, displayName: memberKey, rankLabel: null, leader: false, online: false, userId: null, ...extra,
})
const activeTeams = () => store.teams.filter((t) => t.status === 'active')
const activeMembers = (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active')
beforeEach(() => {
store = freshStore()
registries._reset()
teamSync._reset()
stubDb()
// The settings read is the only other database touch on this path.
patch(settings, 'get', async () => null)
})
afterEach(() => {
teamSync._reset()
registries._reset()
restore()
})
// ── Gate 1: a failed getTeams() touches nothing ────────────────────────────
test('gate 1 — a provider that cannot answer leaves every row untouched', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }) })
await teamSync.reconcileNow('setup')
// Compared as JSON on both sides: the rows carry Date objects, and a snapshot
// taken through JSON would otherwise "differ" from the live rows purely by
// having stringified them.
const before = JSON.stringify(store.teams)
assert.equal(store.teams.length, 1)
registries._reset()
provide({ getTeams: async () => ({ ok: false, reason: 'sidecar unreachable' }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.ok, false)
assert.equal(JSON.stringify(store.teams), before, 'not a single row may change')
assert.equal(store.sync.get('uo').consecutive_failures, 1)
assert.equal(store.sync.get('uo').last_error, 'sidecar unreachable')
})
test('gate 1 — a hung or throwing provider is the same refusal, not an empty list', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => { throw new Error('EPIPE') } })
await teamSync.reconcileNow('test')
assert.equal(activeTeams().length, 1, 'a thrown error must never read as "no teams"')
})
test('failures accumulate and a success clears them', async () => {
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
await teamSync.reconcileNow('a')
await teamSync.reconcileNow('b')
assert.equal(store.sync.get('uo').consecutive_failures, 2)
registries._reset()
provide()
await teamSync.reconcileNow('c')
assert.equal(store.sync.get('uo').consecutive_failures, 0)
assert.equal(store.sync.get('uo').last_error, null)
})
// ── Gate 2: an authoritative empty list is quarantined ─────────────────────
test('gate 2 — the first empty answer archives nothing', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
await teamSync.reconcileNow('setup')
assert.equal(activeTeams().length, 2)
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.quarantined, true)
assert.equal(activeTeams().length, 2, 'a cold start must not empty the site')
assert.ok(store.sync.get('uo').pending_empty_since, 'the answer is remembered')
})
test('gate 2 — a second empty answer, an interval later, is applied', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
await teamSync.reconcileNow('first empty')
// Age the quarantine past one full interval.
store.sync.get('uo').pending_empty_since = new Date(Date.now() - (teamSync.DEFAULT_INTERVAL_S + 1) * 1000)
const result = await teamSync.reconcileNow('second empty')
assert.equal(result.archived, 1, 'every team on the shard really did disband')
assert.equal(activeTeams().length, 0)
assert.equal(store.teams[0].archived_reason, 'disbanded')
})
test('gate 2 — a second empty answer TOO SOON is still quarantined', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
await teamSync.reconcileNow('first')
const result = await teamSync.reconcileNow('second, immediately')
assert.equal(result.quarantined, true, 'two answers a second apart are one cold start, not two')
assert.equal(activeTeams().length, 1)
})
test('gate 2 — any non-empty answer clears the quarantine', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
await teamSync.reconcileNow('empty')
assert.ok(store.sync.get('uo').pending_empty_since)
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('recovered')
assert.equal(store.sync.get('uo').pending_empty_since, null)
})
test('gate 2 — an empty list with nothing held is not a quarantine, just nothing to do', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.ok, true)
assert.notEqual(result.quarantined, true)
})
test('an incomplete answer never removes, so an empty partial list is harmless', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, complete: false, teams: [] }) })
const result = await teamSync.reconcileNow('partial')
assert.equal(result.archived, 0)
assert.equal(activeTeams().length, 1)
assert.ok(!store.sync.get('uo').pending_empty_since, 'no quarantine needed — nothing was at risk')
})
// ── Gate 3: one Team's unanswerable roster ─────────────────────────────────
test('gate 3 — a refused roster leaves that team alone and the others sync', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
getTeamMembers: async (id) => (id === 'g1'
? { ok: true, members: [member('0x1'), member('0x2')] }
: { ok: true, members: [member('0x9')] }),
})
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2)
assert.equal(activeMembers(2).length, 1)
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
getTeamMembers: async (id) => (id === 'g1'
? { ok: false, reason: 'roster unavailable' }
: { ok: true, members: [member('0x9'), member('0xA')] }),
})
const result = await teamSync.reconcileNow('test')
assert.equal(activeMembers(1).length, 2, "g1's roster is untouched, not emptied")
assert.equal(activeMembers(2).length, 2, "g2 syncs normally — one team's problem is its own")
assert.equal(result.rosters, 1, 'only one roster was applied')
})
test('gate 3 — a refused roster does not bump that teams freshness stamp', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
await teamSync.reconcileNow('setup')
const syncedAt = store.teams[0].roster_synced_at
assert.ok(syncedAt)
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: false, reason: 'nope' }),
})
await teamSync.reconcileNow('test')
assert.equal(store.teams[0].roster_synced_at, syncedAt, 'stale must show as stale, not as just-synced')
})
test('leadership is a separate answer — a refused one does not demote anybody', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
})
await teamSync.reconcileNow('setup')
assert.equal(membersOf(1).get('0x1').is_leader, 1)
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
})
await teamSync.reconcileNow('test')
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'an unanswerable question is not the answer "nobody"')
})
test('the roster seeds is_leader on a new row but never overwrites it afterwards', async () => {
// Two writers for one column is how a refused leadership answer becomes a
// silent demotion: the roster would write `leader: false` before the
// authoritative call was even made. Seeding on insert keeps a Team from being
// leaderless while getTeamLeaders() is failing.
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: true })] }),
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
})
await teamSync.reconcileNow('first sync, leadership unanswerable')
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'seeded from the roster rather than left blank')
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: false })] }),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
})
await teamSync.reconcileNow('roster disagrees with the authority')
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'getTeamLeaders() is path 2, and the roster is not')
})
// ── Gate 4: an authoritative empty roster ──────────────────────────────────
test('gate 4 — the first empty roster departs nobody', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2)
members = []
await teamSync.reconcileNow('empty roster')
assert.equal(activeMembers(1).length, 2, 'a cold cache must not empty a roster')
assert.ok(store.teams[0].members_empty_since)
})
test('gate 4 — a second empty roster is applied', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = []
await teamSync.reconcileNow('first empty')
await teamSync.reconcileNow('second empty')
assert.equal(activeMembers(1).length, 0, 'the guild really was emptied')
assert.equal(store.teams[0].member_count, 0)
})
test('gate 4 — a non-empty roster clears the quarantine', async () => {
let members = [member('0x1')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = []
await teamSync.reconcileNow('empty')
assert.ok(store.teams[0].members_empty_since)
members = [member('0x1')]
await teamSync.reconcileNow('recovered')
assert.equal(store.teams[0].members_empty_since, null)
})
test('gate 4 — a team that never had members takes an empty roster at once', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.rosters, 1, 'nothing is at risk, so nothing is quarantined')
assert.equal(store.teams[0].members_empty_since, null)
})
// ── Ordinary syncing ───────────────────────────────────────────────────────
test('a new team is created with a slug, and its roster and counts follow', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand', { abbr: 'TSH' })] }),
getTeamMembers: async () => ({
ok: true,
members: [member('0x1', { userId: 7, online: true }), member('0x2')],
}),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
})
const result = await teamSync.reconcileNow('test')
assert.equal(result.created, 1)
const row = store.teams[0]
assert.equal(row.slug, 'the-silver-hand')
assert.equal(row.member_count, 2)
assert.equal(row.linked_count, 1)
assert.equal(row.online_count, 1)
assert.equal(membersOf(1).get('0x1').is_leader, 1)
})
test('a member who disappears from a complete roster is departed, not deleted', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = [member('0x1')]
await teamSync.reconcileNow('test')
assert.equal(membersOf(1).get('0x2').status, 'departed', 'the row survives so history and rejoins do')
assert.equal(activeMembers(1).length, 1)
})
test('a rejoining member revives their row and keeps their first_seen_at', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = [member('0x1')]
await teamSync.reconcileNow('leaves')
members = [member('0x1'), member('0x2')]
await teamSync.reconcileNow('returns')
assert.equal(membersOf(1).get('0x2').status, 'active')
assert.equal(membersOf(1).get('0x2').first_seen_at, 'first', 'a rejoin is a revived row, not a second one')
})
test('an incomplete roster adds and updates but removes nothing', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
})
await teamSync.reconcileNow('setup')
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x3')] }),
})
await teamSync.reconcileNow('partial')
assert.equal(activeMembers(1).length, 3, 'a partial answer is not a claim about who is absent')
})
test('a team absent from a complete list is archived as disbanded', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('test')
assert.equal(activeTeams().length, 1)
const archived = store.teams.find((t) => t.external_id === 'g2')
assert.equal(archived.status, 'archived')
assert.equal(archived.archived_reason, 'disbanded')
})
// ── The rename rule (§2.2) ─────────────────────────────────────────────────
test('a renamed team is archived and succeeded, never edited in place', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
await teamSync.reconcileNow('setup')
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
const result = await teamSync.reconcileNow('rename')
assert.equal(result.renamed, 1)
const [old_, next] = store.teams
assert.equal(old_.name, 'The Silver Hand', 'the name is immutable for the life of the row')
assert.equal(old_.status, 'archived')
assert.equal(old_.archived_reason, 'renamed')
assert.equal(old_.succeeded_by, next.id, 'the old slug can explain itself instead of 404ing')
assert.equal(next.name, 'The Golden Hand')
assert.equal(next.slug, 'the-golden-hand')
assert.equal(next.status, 'active')
})
test('a rename back to a previous name does not reuse the retired slug', async () => {
const names = ['Alpha', 'Beta', 'Alpha']
let i = 0
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', names[i])] }) })
await teamSync.reconcileNow('a')
i = 1
await teamSync.reconcileNow('b')
i = 2
await teamSync.reconcileNow('c')
const slugs = store.teams.map((t) => t.slug)
assert.deepEqual(slugs, ['alpha', 'beta', 'alpha-2'])
assert.equal(new Set(slugs).size, 3, 'an archived team stays readable at its own address')
})
test('two teams with the same name get distinct slugs', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Guard'), team('g2', 'Guard')] }) })
await teamSync.reconcileNow('test')
assert.deepEqual(store.teams.map((t) => t.slug), ['guard', 'guard-2'])
})
test('a name with nothing URL-safe in it still gets an address', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', '★☆★')] }) })
await teamSync.reconcileNow('test')
assert.equal(store.teams[0].slug, 'team')
assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed')
})
// ── Events (§2.3) ──────────────────────────────────────────────────────────
test('an unknown event kind is rejected', async () => {
provide()
await assert.rejects(() => teamSync.publish({ kind: 'team.exploded', externalId: 'g1' }), /unknown event kind/)
})
test('an event with no externalId is rejected', async () => {
provide()
await assert.rejects(() => teamSync.publish({ kind: 'team.member.added' }), /no externalId/)
})
test('team.disbanded never archives — it asks for a reconciliation', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
await teamSync.publish({ kind: 'team.disbanded', externalId: 'g1' })
assert.equal(activeTeams().length, 1, 'destruction is never driven by a delta that may be a repeat')
})
test('team.created does not invent a team', async () => {
provide()
await teamSync.publish({ kind: 'team.created', externalId: 'brand-new' })
assert.equal(store.teams.length, 0, 'a team built from a delta has no name, roster or leaders')
})
test('a member delta applies at once for a known team and updates the counts', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
await teamSync.publish({
kind: 'team.member.added', externalId: 'g1', memberKey: '0x5', displayName: 'Ada', userId: 3,
})
assert.equal(activeMembers(1).length, 1)
assert.equal(membersOf(1).get('0x5').display_name, 'Ada')
assert.equal(store.teams[0].member_count, 1)
assert.equal(store.teams[0].linked_count, 1)
await teamSync.publish({ kind: 'team.member.removed', externalId: 'g1', memberKey: '0x5' })
assert.equal(activeMembers(1).length, 0)
assert.equal(store.teams[0].member_count, 0)
})
test('a leadership delta writes is_leader and nothing else', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
await teamSync.reconcileNow('setup')
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0x1' })
assert.equal(membersOf(1).get('0x1').is_leader, 1)
assert.equal(activeMembers(1).length, 1, 'promotion is not a join')
await teamSync.publish({ kind: 'team.leader.removed', externalId: 'g1', memberKey: '0x1' })
assert.equal(membersOf(1).get('0x1').is_leader, 0)
assert.equal(activeMembers(1).length, 1, 'demotion is not a departure')
})
test('a leadership delta for an unknown member creates nobody', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0xdead' })
assert.equal(activeMembers(1).length, 0, 'a promotion is not evidence of membership')
})
test('an event for an unknown team asks for a reconciliation instead of guessing', async () => {
provide()
await teamSync.publish({ kind: 'team.member.added', externalId: 'nope', memberKey: '0x1' })
assert.equal(store.teams.length, 0)
})
test('publish is a no-op when no provider is registered', async () => {
await teamSync.publish({ kind: 'team.member.added', externalId: 'g1', memberKey: '0x1' })
assert.equal(store.teams.length, 0)
})
// ── Scheduling ─────────────────────────────────────────────────────────────
test('a run already in flight is joined rather than run twice', async () => {
let calls = 0
let release
const gate = new Promise((resolve) => { release = resolve })
provide({
getTeams: async () => {
calls += 1
await gate
return { ok: true, teams: [] }
},
})
const first = teamSync.reconcileNow('first')
const second = await teamSync.reconcileNow('second')
assert.equal(second.joined, true)
release()
await first
assert.equal(calls, 1, 'the lock is what stops two runs writing the same rows')
})
test('the poll interval falls back and is floored against a bad setting', async () => {
patch(settings, 'get', async () => 'not a number')
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
patch(settings, 'get', async () => '5')
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S, 'a hot loop is not a valid interval')
patch(settings, 'get', async () => '120')
assert.equal(await teamSync.intervalSeconds(), 120)
patch(settings, 'get', async () => { throw new Error('db down') })
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
})
test('backoff grows with failures and is capped at the poll interval', async () => {
assert.equal(teamSync.backoffSeconds(0, 900), 900, 'no failures means the ordinary poll')
assert.equal(teamSync.backoffSeconds(1, 900), 30)
assert.equal(teamSync.backoffSeconds(2, 900), 60)
assert.ok(teamSync.backoffSeconds(4, 900) < 900)
assert.equal(teamSync.backoffSeconds(50, 900), 900, 'a module down for a day must recover promptly, not in weeks')
})
test('start() is inert with no provider registered', async () => {
await teamSync.start()
assert.equal(store.teams.length, 0)
})