// ── First-party clans → core's Teams (phase 9) ───────────────────────────── // // The properties this suite holds, each with a failure behind it: // // • a clan's identity carries its creation time (D52), so a reset clan // database cannot hand an old clan's Team to a new one; // • only a COMPLETE board may say a clan is gone — a board at the game's // 100-clan ceiling (D55), or one with an unreadable row, proves nothing // about what it leaves out; // • leadership is learned from the board, diffed (D54); // • `getTeams` is complete only when EVERY server vouches (D53), and refuses // rather than answering empty when none does; // • a roster is shown to the clan's own members and staff by default (D48), // re-read from the users row, and a failure withholds it; // • every feed item is members-only (D49) and carries a dedupe key core will // not truncate into a collision. const test = require('node:test') const assert = require('node:assert') const { fakeCtx, spy } = require('./_fakes') const SERVER = 'main' const T0 = 1790142840000 function member(steamId, rank = 2, extra = {}) { return { steamId, rank, role: rank === 1 ? 'Leader' : 'Member', joinedMs: T0, name: `P${steamId.slice(-2)}`, ...extra } } function clanRow(clanId, createdMs, members, extra = {}) { return { clanId, createdMs, name: `Clan ${clanId}`, color: '#3FA9F5', score: 10, maxMembers: 100, members, ...extra } } /** * The model and provider over an in-memory store, with a chosen viewer row. * * The store is small enough to reason about: clans and members by external id, * and one board record per server. Every `clans.db` function the code under test * calls is replaced; anything else it reached for would throw on the fake ctx. */ function setup({ users = {}, rosterSetting = null, servers = [{ id: SERVER }] } = {}) { require('../core')._reset() const ctx = fakeCtx({ users: { getById: async (id) => users[id] || null }, }) require('../core').init(ctx) const db = require('../model/clans/clans.db') const visibilityDb = require('../model/visibility/visibility.db') const serversModel = require('../model/servers/servers.model') const store = { clans: new Map(), members: new Map(), boards: new Map(), links: new Map(), online: new Set(), names: [] } const originals = { db: { ...db }, visibilityDb: { ...visibilityDb }, servers: { ...serversModel } } db.getBoard = async (serverId) => store.boards.get(serverId) || null db.listBoards = async () => servers.map((s) => ({ serverId: s.id, serverName: s.id.toUpperCase(), ...(store.boards.get(s.id) || {}) })) db.putBoard = async (b) => { const prev = store.boards.get(b.serverId) || {} store.boards.set(b.serverId, { serverId: b.serverId, boardT: b.boardT, seenAt: b.advanced ? new Date() : prev.seenAt || null, enabled: b.enabled ? 1 : 0, supported: b.supported ? 1 : 0, truncated: b.truncated ? 1 : 0, backend: b.backend, reason: b.reason, umodClans: b.umodClans ? 1 : 0, clanCount: b.clanCount, }) } db.listClansForServer = async (serverId) => [...store.clans.values()].filter((c) => c.serverId === serverId) db.listMembersForServer = async (serverId) => { const out = [] for (const c of store.clans.values()) { if (c.serverId !== serverId || c.goneAt) continue for (const m of store.members.get(c.externalId) || []) out.push({ externalId: c.externalId, ...m }) } return out } db.upsertClan = async (c) => { const prev = store.clans.get(c.externalId) store.clans.set(c.externalId, { ...prev, ...c, members: undefined, goneAt: null }) } db.replaceMembers = spy(async (externalId, members) => { store.members.set(externalId, members.map((m) => ({ ...m }))) }) db.markGone = async (ids) => { for (const id of ids) { const c = store.clans.get(id) if (c && !c.goneAt) c.goneAt = new Date() store.members.delete(id) } } db.findClan = async (id) => { const c = store.clans.get(id) return c ? { ...c, serverName: c.serverId.toUpperCase() } : null } db.findByGameId = async (serverId, clanId) => { const hits = [...store.clans.values()] .filter((c) => c.serverId === serverId && c.clanId === clanId) .sort((a, b) => b.createdMs - a.createdMs) return hits[0] ? { externalId: hits[0].externalId, name: hits[0].name } : null } db.listActiveClans = async () => [...store.clans.values()].filter((c) => !c.goneAt).map((c) => ({ ...c, serverName: c.serverId.toUpperCase() })) db.listPublicForServer = async (serverId) => [...store.clans.values()].filter((c) => c.serverId === serverId && !c.goneAt) db.listMembers = async (externalId) => { const c = store.clans.get(externalId) return (store.members.get(externalId) || []).map((m) => ({ ...m, userId: store.links.get(m.steamId) || null, online: c && store.online.has(m.steamId) ? 1 : 0, })) } db.userIsMember = async (externalId, userId) => (store.members.get(externalId) || []).some((m) => store.links.get(m.steamId) === userId) db.recentClanEvents = async () => store.recent || [] db.rememberName = async (steamId, name) => store.names.push({ steamId, name }) visibilityDb.getSetting = async (key) => (key === 'clans.roster.audience' ? rosterSetting : null) serversModel.listForPolling = async () => servers const clans = require('../model/clans/clans.model') const provider = require('../model/clans/teamProvider') return { ctx, store, clans, provider, restore: () => { Object.assign(db, originals.db) Object.assign(visibilityDb, originals.visibilityDb) Object.assign(serversModel, originals.servers) }, } } const board = (clans, extra = {}) => ({ kind: 'clans', type: 'snapshot', t: T0, supported: true, truncated: false, enabled: true, clans, ...extra }) // ── Identity ─────────────────────────────────────────────────────────────── test('a clan is keyed on server, game id AND creation time (D52)', () => { const { clans, restore } = setup() try { const a = clans.normaliseClan(SERVER, clanRow(1, T0, [member('76561198000000001', 1)])) const b = clans.normaliseClan(SERVER, clanRow(1, T0 + 5000, [member('76561198000000001', 1)])) // Same game id, different clan: a reset database re-used id 1. assert.notStrictEqual(a.externalId, b.externalId) assert.strictEqual(a.externalId, `main:1:${T0}`) // No id, no creation time, or no name: there is nothing to key it on. assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, name: 'x' }), null) assert.strictEqual(clans.normaliseClan(SERVER, { createdMs: T0, name: 'x' }), null) assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, createdMs: T0 }), null) // A colour ends up in a style, so anything that is not #rrggbb is dropped. assert.strictEqual(clans.normaliseClan(SERVER, clanRow(2, T0, [], { color: 'red;background:url(x)' })).color, null) // A member whose Steam id is not one is dropped, not the clan. const partial = clans.normaliseClan(SERVER, clanRow(3, T0, [member('7656'), { steamId: 'robert' }])) assert.deepStrictEqual(partial.members.map((m) => m.steamId), ['7656']) } finally { restore() } }) // ── The board ────────────────────────────────────────────────────────────── test('a first board stores its clans and asks core to reconcile', async () => { const { ctx, store, clans, restore } = setup() try { const result = await clans.applyBoard(SERVER, board([ clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002')]), ])) assert.strictEqual(result.applied, true) assert.strictEqual(result.created, 1) assert.strictEqual(store.clans.size, 1) assert.strictEqual(store.members.get(`main:1:${T0}`).length, 2) assert.strictEqual(ctx.teams.reconcile.calls.length, 1) // Leaders of a brand-new clan reach core WITH the Team, not as a delta // against a Team core does not hold yet. assert.strictEqual(ctx.teams.publish.calls.length, 0) } finally { restore() } }) test('a board whose t has not moved is not applied again', async () => { const { ctx, clans, store, restore } = setup() try { const b = board([clanRow(1, T0, [member('76561198000000001', 1)])]) await clans.applyBoard(SERVER, b) store.members.clear() const again = await clans.applyBoard(SERVER, b) assert.strictEqual(again.applied, false) assert.strictEqual(store.members.size, 0, 'nothing was rewritten') assert.strictEqual(ctx.teams.reconcile.calls.length, 1) } finally { restore() } }) test('an unchanged roster is not rewritten when the board moves on', async () => { const { clans, restore } = setup() const db = require('../model/clans/clans.db') try { const members = [member('76561198000000001', 1)] await clans.applyBoard(SERVER, board([clanRow(1, T0, members)])) const writes = db.replaceMembers.calls.length await clans.applyBoard(SERVER, board([clanRow(1, T0, members)], { t: T0 + 60000 })) assert.strictEqual(db.replaceMembers.calls.length, writes) } finally { restore() } }) test('a complete board says a missing clan is gone; a truncated one does not (D55)', async () => { const { clans, store, restore } = setup() try { await clans.applyBoard(SERVER, board([ clanRow(1, T0, [member('76561198000000001', 1)]), clanRow(2, T0, [member('76561198000000002', 1)]), ])) // At the ceiling: clan 2 is not listed, and that proves nothing. await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 60000, truncated: true })) assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null) // A row this build could not read counts the same way. await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)]), { name: 'broken' }], { t: T0 + 90000 })) assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null) // Complete, and still not listed: now it is gone. await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 120000 })) assert.ok(store.clans.get(`main:2:${T0}`).goneAt) } finally { restore() } }) test('a change of leader is published from the board diff (D54)', async () => { const { ctx, clans, restore } = setup() try { await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002', 2)])])) await clans.applyBoard(SERVER, board( [clanRow(1, T0, [member('76561198000000001', 2), member('76561198000000002', 1)])], { t: T0 + 60000 }, )) const kinds = ctx.teams.publish.calls.map(([e]) => `${e.kind}:${e.memberKey}`).sort() assert.deepStrictEqual(kinds, [ 'team.leader.added:76561198000000002', 'team.leader.removed:76561198000000001', ]) } finally { restore() } }) test('an unsupported board is recorded with its reason and touches no clan', async () => { const { clans, store, restore } = setup() try { await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])])) const result = await clans.applyBoard(SERVER, { kind: 'clans', t: T0 + 60000, supported: false, reason: 'held by a NexusClanBackend', clans: [], }) assert.strictEqual(result.applied, false) assert.strictEqual(store.boards.get(SERVER).supported, 0) assert.match(store.boards.get(SERVER).reason, /Nexus/) assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null, 'an unreadable server says nothing about its clans') // No board at all: a plugin older than protocol 6. Recorded, nothing touched. await clans.applyBoard(SERVER, undefined) assert.match(store.boards.get(SERVER).reason, /protocol 6/) assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null) } finally { restore() } }) // ── The events ───────────────────────────────────────────────────────────── test('each clan event is published as the Team kind core takes', async () => { const { ctx, clans, restore } = setup() try { const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 } await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' }) await clans.applyEvent(SERVER, { kind: 'clan.member.added', ...base, steamId: '76561198000000002', name: 'Bob' }) await clans.applyEvent(SERVER, { kind: 'clan.member.left', ...base, steamId: '76561198000000002', name: 'Bob' }) await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', bySteamId: '76561198000000001' }) assert.deepStrictEqual(ctx.teams.publish.calls.map(([e]) => [e.kind, e.memberKey]), [ ['team.created', undefined], ['team.member.added', '76561198000000002'], ['team.member.removed', '76561198000000002'], ['team.member.removed', '76561198000000003'], ]) for (const [e] of ctx.teams.publish.calls) assert.strictEqual(e.externalId, `main:1:${T0}`) } finally { restore() } }) test('every feed item is members-only, and its dedupe key fits core’s 40 characters', async () => { const { ctx, clans, restore } = setup() try { const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 } await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' }) await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', name: 'Cy', byName: 'Ann', bySteamId: '76561198000000001' }) await clans.applyEvent(SERVER, { kind: 'clan.disbanded', ...base, steamId: '76561198000000001' }) const items = ctx.teams.activity.push.calls.map(([batch]) => batch[0]) // D49: founded and removed made lines; the disband did not. assert.deepStrictEqual(items.map((i) => i.kind), ['rust.clan.founded', 'rust.clan.removed']) assert.strictEqual(items[0].summary, 'Ann founded the clan.') assert.strictEqual(items[1].summary, 'Cy was removed from the clan by Ann.') assert.strictEqual(items[1].actorMemberKey, '76561198000000001', 'the actor of a kick is the kicker') for (const item of items) { assert.strictEqual(item.visibility, 'members') // Core clamps a dedupe key to 40 characters. A readable one would be cut // short into collisions; a sha1 is exactly 40. assert.match(item.dedupeKey, /^[0-9a-f]{40}$/) assert.strictEqual(typeof item.occurredAt, 'number', 'core reads occurredAt as epoch ms') } assert.notStrictEqual(items[0].dedupeKey, items[1].dedupeKey) } finally { restore() } }) test('the same frame offered twice carries the same key, so a re-offer is a no-op', async () => { const { ctx, clans, store, restore } = setup() try { const frame = { kind: 'clan.member.added', clanId: 1, createdMs: T0, t: T0 + 5, steamId: '76561198000000002', name: 'Bob' } await clans.applyEvent(SERVER, frame) store.recent = [{ id: 1, kind: frame.kind, t: frame.t, raw: JSON.stringify(frame) }] const offered = await clans.reofferActivity(SERVER) assert.strictEqual(offered, 1) const [first, second] = ctx.teams.activity.push.calls.map(([batch]) => batch[0].dedupeKey) assert.strictEqual(first, second) } finally { restore() } }) test('a join without a creation time is matched on the game id, newest clan first', async () => { const { ctx, clans, restore } = setup() try { await clans.applyBoard(SERVER, board([ clanRow(1, T0, [member('76561198000000001', 1)]), ])) const result = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 1, t: T0 + 1, steamId: '76561198000000009' }) assert.strictEqual(result.externalId, `main:1:${T0}`) // A clan this module has never heard of is skipped, not guessed at. const unknown = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 77, t: T0 + 2, steamId: '76561198000000009' }) assert.strictEqual(unknown.applied, false) assert.ok(ctx.teams.publish.calls.every(([e]) => e.externalId === `main:1:${T0}`)) } finally { restore() } }) test('a disband marks the clan gone even when the board could not say so', async () => { const { clans, store, restore } = setup() try { await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { truncated: true })) await clans.applyEvent(SERVER, { kind: 'clan.disbanded', clanId: 1, createdMs: T0, t: T0 + 1, steamId: '76561198000000001' }) assert.ok(store.clans.get(`main:1:${T0}`).goneAt) } finally { restore() } }) // ── The provider ─────────────────────────────────────────────────────────── test('getTeams is complete only when every server vouches (D53)', async () => { const both = [{ id: 'main' }, { id: 'pvp' }] const { clans, provider, restore } = setup({ servers: both }) try { // Neither server has a board: refuse, never "no teams". const none = await provider.getTeams() assert.strictEqual(none.ok, false) // One current, one never heard from: partial, so core removes nothing. await clans.applyBoard('main', board([clanRow(1, T0, [member('76561198000000001', 1)])])) const partial = await provider.getTeams() assert.strictEqual(partial.ok, true) assert.strictEqual(partial.complete, false) assert.deepStrictEqual(partial.teams.map((t) => t.externalId), [`main:1:${T0}`]) assert.strictEqual(partial.teams[0].meta.serverId, 'main') // Both current: complete. await clans.applyBoard('pvp', board([])) assert.strictEqual((await provider.getTeams()).complete, true) // One at the ceiling: partial again. await clans.applyBoard('pvp', board([], { t: T0 + 60000, truncated: true })) assert.strictEqual((await provider.getTeams()).complete, false) } finally { restore() } }) test('a board that stops advancing stops vouching', async () => { const { store, clans, provider, restore } = setup() try { await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])])) assert.strictEqual((await provider.getTeams()).ok, true) store.boards.get(SERVER).seenAt = new Date(Date.now() - clans.FRESH_MS - 1000) assert.strictEqual((await provider.getTeams()).ok, false) assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false) } finally { restore() } }) test('getTeams refuses on a site with no Rust servers', async () => { const { provider, restore } = setup({ servers: [] }) try { const answer = await provider.getTeams() assert.deepStrictEqual(answer.ok, false) assert.match(answer.reason, /no Rust servers/) } finally { restore() } }) test('a roster names its members, its leaders, the linked account and who is on', async () => { const { store, clans, provider, restore } = setup() try { await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002'), member('76561198000000003', null, { rank: null, role: null })])])) store.links.set('76561198000000002', 42) store.online.add('76561198000000001') const roster = await provider.getTeamMembers(`main:1:${T0}`) assert.strictEqual(roster.ok, true) const byKey = Object.fromEntries(roster.members.map((m) => [m.memberKey, m])) assert.strictEqual(byKey['76561198000000001'].leader, true) assert.strictEqual(byKey['76561198000000001'].online, true) assert.strictEqual(byKey['76561198000000002'].userId, 42) // A rank the board could not match is not a leader. assert.strictEqual(byKey['76561198000000003'].leader, false) const leaders = await provider.getTeamLeaders(`main:1:${T0}`) assert.deepStrictEqual(leaders, { ok: true, leaders: ['76561198000000001'] }) // A clan with a count but no stored rows is a read between two writes. store.members.set(`main:1:${T0}`, []) assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false) } finally { restore() } }) // ── Who may see a roster (D48) ───────────────────────────────────────────── const USERS = { 1: { id: 1, role: 'player', status: 'active' }, // linked to a member 2: { id: 2, role: 'player', status: 'active' }, // not a member 3: { id: 3, role: 'moderator', status: 'active' }, 4: { id: 4, role: 'player', status: 'banned' }, // linked to a member, banned } async function rosterFixture(options) { const fx = setup({ users: USERS, ...options }) await fx.clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000004')])])) fx.store.links.set('76561198000000001', 1) fx.store.links.set('76561198000000004', 4) return fx } const keysFor = async (provider, viewer) => (await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }, { member_key: '76561198000000004' }], viewer)).members.length test('by default a roster is for the clan’s own members and staff', async () => { const { provider, restore } = await rosterFixture() try { assert.strictEqual(await keysFor(provider, null), 0, 'anonymous') assert.strictEqual(await keysFor(provider, { userId: 2, role: 'player' }), 0, 'a stranger') assert.strictEqual(await keysFor(provider, { userId: 1, role: 'player' }), 2, 'a member') assert.strictEqual(await keysFor(provider, { userId: 3, role: 'moderator' }), 2, 'staff') // The row, not the claim: a banned member sees nothing, and a claimed role // the row does not hold grants nothing. assert.strictEqual(await keysFor(provider, { userId: 4, role: 'player' }), 0, 'banned') assert.strictEqual(await keysFor(provider, { userId: 2, role: 'admin' }), 0, 'a claim is not a role') } finally { restore() } }) test('the operator can widen it, and an unknown setting narrows back', async () => { const signedIn = await rosterFixture({ rosterSetting: 'signed_in' }) try { assert.strictEqual(await keysFor(signedIn.provider, { userId: 2, role: 'player' }), 2) assert.strictEqual(await keysFor(signedIn.provider, null), 0) } finally { signedIn.restore() } const open = await rosterFixture({ rosterSetting: 'public' }) try { assert.strictEqual(await keysFor(open.provider, null), 2) } finally { open.restore() } const typo = await rosterFixture({ rosterSetting: 'everyone' }) try { assert.strictEqual(await keysFor(typo.provider, { userId: 2, role: 'player' }), 0) } finally { typo.restore() } }) test('a roster question that cannot be answered withholds the roster', async () => { const { provider, restore } = await rosterFixture() const visibilityDb = require('../model/visibility/visibility.db') try { visibilityDb.getSetting = async () => { throw new Error('pool exhausted') } const answer = await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }], { userId: 3 }) // Core fails CLOSED on this one call: a refusal serves an empty roster. assert.strictEqual(answer.ok, false) } finally { restore() } }) test('the clan page carries no Steam id and no account id, and no names below the audience', async () => { const { clans, restore } = await rosterFixture() try { const outside = await clans.getForViewer(`main:1:${T0}`, null) assert.strictEqual(outside.roster.visible, false) assert.deepStrictEqual(outside.roster.members, []) assert.strictEqual(outside.clan.memberCount, 2, 'the count is public (D58)') const inside = await clans.getForViewer(`main:1:${T0}`, { userId: 1 }) assert.strictEqual(inside.roster.visible, true) assert.strictEqual(inside.roster.members.length, 2) for (const m of inside.roster.members) { assert.ok(!('steamId' in m) && !('userId' in m), 'no identifier leaves on a roster row') } assert.strictEqual(await clans.getForViewer('main:99:1', null), null) } finally { restore() } })