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>
740 lines
30 KiB
JavaScript
740 lines
30 KiB
JavaScript
// 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 team’s 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)
|
||
})
|