A first-party Rust clan is a Team (R5). This module becomes the site's Team provider and answers core from the plugin's `clans` board. Design of record: docs/modules/rust/PLAN.md §24, D47-D58. - The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's identity is <serverId>:<clanId>:<createdMs> (D52), because the game restarts clan ids whenever its clan database version changes. - The provider (D53): getTeams is complete only when every server's board is fresh, supported and untruncated. It is partial when some are, and refuses when none are. Freshness is judged by the website's clock, from when the board's `t` last advanced. - Only a complete board may mark a clan 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 diffed board to board and published (D54). The five clan events are published as team.* kinds, and written to the Team feed as members-only lines (D49). - Core only writes feed items for a Team it already holds. So the last 10 minutes of clan events are re-offered on each board refresh, deduped by a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key would be truncated into collisions. - projectRoster and the clan page share one audience rule (D48): the clan's linked members and staff by default, re-read from the users row. The setting lives on Admin > Rust visibility, which also warns about uMod Clans (D47) and the ceiling. - Public: GET servers/:id/clans (the list is public, D58) and GET clans/:externalId. The client adds a Clans tab and /rust/clans/:externalId, with three module slots for core's notify, activity and forum contributions (D56). - Linking and unlinking an account ask core to reconcile Teams (D57). - The clan kinds are staff-class in the public feed allowlist. - PROTOCOL_VERSION is now 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
553 lines
24 KiB
JavaScript
553 lines
24 KiB
JavaScript
// ── 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()
|
||
}
|
||
})
|