Files
website/server/test/teamSync.test.js
wtclaude 5fa88baa0a test(teams): the refusals, which is most of what a notification feature is
A notification feature is mostly things that correctly do NOT happen, and each of
these is invisible until it goes wrong in production: a departed member and a
revoked guest are not recipients; a mute subtracts per Team and leaves the user's
other Teams alone; the author of a post never receives the notification about it;
forums switched off silences the forum streams including the digest; a Team's
first roster wakes nobody; a failed send does not stamp `last_digest_at`.

Two real defects came out of writing them.

`Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived
`filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id
0, so it was harmless — which is exactly why it would never have been noticed.
Fixed in all three places that filter ids.

`recipientIds: db.recipientIds` in the model captured the function OBJECT at
require time, so the layer below could never be substituted. That is not only
untestable; it means the model was not really the seam it claimed to be. Wrapped
so `db.x` resolves at call time.

The registries catalog assertion is now an exact five-element list, so a
shard-content stream creeping back into core's registration fails here rather
than shipping.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:23 -05:00

1019 lines
42 KiB
JavaScript
Raw Permalink 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 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 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')
})
// ── 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')
})