// 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 moderation = require('../src/model/teams/teamModeration.model') const activity = require('../src/model/teams/teamActivity.model') const teamNotify = require('../src/utils/teamNotify') 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)) // The sync reads the full rows, not just the keys: the activity feed needs each // changing member's display name and PRIOR is_leader, both of which the upsert // is about to overwrite. Stubbing this is not optional — an unstubbed seam here // reaches the real pool, and the symptom is the suite hanging on dead-pool // retries rather than failing (see test/_setup.js). patch(teamsDb, 'membersByTeam', async (teamId, { includeDeparted = false } = {}) => [...membersOf(teamId).values()] .filter((m) => includeDeparted || m.status === 'active') .map((m) => ({ ...m }))) 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) }) // Reserved-name screening is its own unit (teamModeration.test.js). Stubbed // here so these tests stay about the reconciler — and because the real calls // read settings, which means a live database connection this suite must never // make. `screened` records that the reconciler asked, which is the integration // point worth asserting from this side. store.screened = [] patch(moderation, 'screenForCreate', async (name) => { store.screened.push(name) return { hidden: false } }) patch(moderation, 'rescreen', async () => 0) // The activity feed is its own unit (teamActivity.test.js); here it is captured // so the reconciler's side of §4.2 can be asserted without a database. Stubbing // the MODEL rather than the db layer keeps these tests about which items the // sync decides to emit, which is the reconciler's half of the contract. store.activity = [] patch(activity, 'logCore', async (item) => { store.activity.push(item) return true }) // The push half of the same run (TEAMS.md §6.2, phase 6). Captured the same way // and for the same reason: the fan-out is its own unit, and what belongs here is // WHEN the reconciler decides to fire one. store.tickles = [] patch(teamNotify, 'memberJoined', async () => { store.tickles.push('member.joined') }) patch(teamNotify, 'leadershipChanged', async () => { store.tickles.push('leadership.changed') }) } // 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') }) // ── Screening is on the create path, and on every run ────────────────────── test('every newly created team has its name screened', async () => { provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin'), team('g2', 'The Silver Hand')] }) }) await teamSync.reconcileNow('test') assert.deepEqual(store.screened, ['Admin', 'The Silver Hand']) }) test('a renamed team is screened again under its new name', async () => { provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Ordinary')] }) }) await teamSync.reconcileNow('setup') registries._reset() provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }) }) await teamSync.reconcileNow('rename') assert.deepEqual(store.screened, ['Ordinary', 'Admin'], 'a rename is a create, so it screens') }) test('a hidden team is still created and still syncs its roster', async () => { // Hide, never reject: the Team works completely for its own members. The people // in it are not being punished for a name their leader chose. patch(moderation, 'screenForCreate', async () => ({ hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin', })) provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }), getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), }) await teamSync.reconcileNow('test') assert.equal(store.teams[0].hidden, 1) assert.equal(store.teams[0].hidden_term, 'admin') assert.equal(activeMembers(1).length, 2, 'suppression is a public-surface rule, not a shutdown') assert.equal(store.teams[0].member_count, 2) }) test('a successful run re-screens the names no human has ruled on', async () => { let called = 0 patch(moderation, 'rescreen', async () => { called += 1; return 0 }) provide() await teamSync.reconcileNow('test') assert.equal(called, 1) }) test('a refused run does not re-screen — it does nothing at all', async () => { let called = 0 patch(moderation, 'rescreen', async () => { called += 1; return 0 }) provide({ getTeams: async () => ({ ok: false, reason: 'down' }) }) await teamSync.reconcileNow('test') assert.equal(called, 0) }) // ── 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) }) // ── Core's own activity items (§4.2) ─────────────────────────────────────── // // The reconciler's half of the feed: which items it DECIDES to emit. The feed's // own rules — visibility, dedupe, retention — live in teamActivity.test.js. const kinds = () => store.activity.map((a) => a.kind) const summaries = () => store.activity.map((a) => a.summary) const withMembers = (members, leaders = []) => ({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), getTeamMembers: async () => ({ ok: true, members }), getTeamLeaders: async () => ({ ok: true, leaders }), }) // Re-provide between runs: the tests above establish that a provider registers // once, so a second answer means a fresh registration. async function resync(overrides, reason) { registries._reset() provide(overrides) return teamSync.reconcileNow(reason) } test('the FIRST roster emits nothing — an import is not 155 people joining', async () => { provide(withMembers([member('0x1'), member('0x2')])) await teamSync.reconcileNow('setup') assert.equal(activeMembers(1).length, 2, 'the members did land') assert.deepEqual(store.activity, [], 'and none of them was announced') // The same suppression, and the half where it matters more: a notification per // imported member would wake every phone in a 155-member guild at once. assert.deepEqual(store.tickles, [], 'and nobody was notified either') }) test('a roster run tickles ONCE per stream, however many members moved', async () => { provide(withMembers([member('0x1')])) await teamSync.reconcileNow('setup') await resync(withMembers([ member('0x1'), member('0x2', { displayName: 'Brenna' }), member('0x3', { displayName: 'Cael' }), ]), 'test') // Two joins, one tickle. The feed above records each of them, because it is a // record; the tickle says "something happened here" and is content-free, so a // second identical one carries no second piece of information. assert.equal(store.activity.length, 2) assert.deepEqual(store.tickles, ['member.joined']) }) test('a leadership change tickles its own stream, separately from joins', async () => { provide(withMembers([member('0x1'), member('0x2')])) await teamSync.reconcileNow('setup') await resync({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), getTeamMembers: async () => ({ ok: true, complete: true, members: [member('0x1'), member('0x2')] }), getTeamLeaders: async () => ({ ok: true, leaders: ['0x2'] }), }, 'test') assert.deepEqual(store.tickles, ['leadership.changed']) }) test('a member arriving after the first roster is announced', async () => { provide(withMembers([member('0x1')])) await teamSync.reconcileNow('setup') await resync(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]), 'test') assert.deepEqual(kinds(), ['core.member.joined']) assert.deepEqual(summaries(), ['Brenna joined']) assert.equal(store.activity[0].actorMemberKey, '0x2') }) test('a member who leaves is announced by the name core last knew them by', async () => { provide(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })])) await teamSync.reconcileNow('setup') await resync(withMembers([member('0x1')]), 'test') // The module no longer mentions them at all, so the display name can only come // from the row core is about to depart — which is why the sync reads the ROWS // before the upsert rather than just the keys. assert.deepEqual(kinds(), ['core.member.left']) assert.deepEqual(summaries(), ['Brenna left']) }) test('an INCOMPLETE answer announces no departures, because it removed none', async () => { provide(withMembers([member('0x1'), member('0x2')])) await teamSync.reconcileNow('setup') await resync({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x1')] }), getTeamLeaders: async () => ({ ok: true, leaders: [] }), }, 'partial') assert.equal(activeMembers(1).length, 2, 'nobody was removed') assert.deepEqual(store.activity, [], 'so nobody is announced as leaving') }) test('promotion and demotion are announced; unchanged leadership is not', async () => { provide(withMembers([member('0x1'), member('0x2')], ['0x1'])) await teamSync.reconcileNow('setup') await resync(withMembers([member('0x1'), member('0x2')], ['0x2']), 'test') assert.deepEqual(kinds(), ['core.leader.changed', 'core.leader.changed']) assert.deepEqual(summaries().sort(), ['0x1 stepped down as a leader', '0x2 became a leader']) }) test('a member who joins already a leader is announced once, as joining', async () => { provide(withMembers([member('0x1')], ['0x1'])) await teamSync.reconcileNow('setup') await resync(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']), 'test') assert.deepEqual(kinds(), ['core.member.joined'], 'never a non-leader here to be promoted from') }) test('a departing leader is announced as leaving, not as stepping down', async () => { provide(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2'])) await teamSync.reconcileNow('setup') await resync(withMembers([member('0x1')], ['0x1']), 'test') assert.deepEqual(kinds(), ['core.member.left'], 'one event, not two') }) test('a refused leadership answer announces nothing — it demoted nobody', async () => { provide(withMembers([member('0x1')], ['0x1'])) await teamSync.reconcileNow('setup') await resync({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), getTeamLeaders: async () => ({ ok: false, reason: 'unavailable' }), }, 'test') assert.deepEqual(store.activity, []) assert.equal(activeMembers(1)[0].is_leader, 1, 'and left the stored value alone') }) test('a rename is announced on the successor, naming the old name', async () => { provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }), getTeamMembers: async () => ({ ok: true, members: [] }), getTeamLeaders: async () => ({ ok: true, leaders: [] }), }) await teamSync.reconcileNow('setup') await resync({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }), getTeamMembers: async () => ({ ok: true, members: [] }), getTeamLeaders: async () => ({ ok: true, leaders: [] }), }, 'rename') const renames = store.activity.filter((a) => a.kind === 'core.team.renamed') assert.equal(renames.length, 1) assert.equal(renames[0].summary, 'Renamed from The Silver Hand') // The SUCCESSOR row, not the archived one: the archived row is a read-only // record of what came before, and the reader is looking at the live page. const successor = store.teams.find((t) => t.status === 'active') assert.equal(renames[0].teamId, successor.id) }) test('a member with no display name is announced without leaking the member key', async () => { provide(withMembers([member('0x1')])) await teamSync.reconcileNow('setup') await resync(withMembers([member('0x1'), member('0x2', { displayName: null })]), 'test') assert.deepEqual(summaries(), ['A member joined']) }) test('a feed write that fails never fails the sync', async () => { patch(activity, 'logCore', async () => { throw new Error('table gone') }) provide(withMembers([member('0x1')])) await teamSync.reconcileNow('setup') const result = await resync(withMembers([member('0x1'), member('0x2')]), 'test') assert.equal(result.ok, true) assert.equal(activeMembers(1).length, 2, 'the roster is the source of truth and it applied') })