The module's half of TEAMS.md phase 3. `projectRoster` is the optional fourth provider method and the only one core calls on a request path. Core holds the roster and owns its public shape; the question that is this module's is who is allowed to look, because the audience rungs and their configuration live here. The answer is all-or-nothing, which is the honest translation rather than a shortcut: a rung is a property of the FEATURE, and there is no configuration in which some members of a guild are public and others are not. The refusal semantics INVERT here, and the tests say so. For the other three methods a refusal means "change nothing" and an empty array would be destructive. Core fails CLOSED on this one, so the dangerous answer is the opposite — returning every key because the config could not be read would publish a roster an operator gated to staff. Every path that cannot reach a confident answer refuses, including the catch. The anonymous case is answered directly rather than by handing `viewerLevel` a synthetic request. Given one with no `req.user` it falls through to `auth.getUserFromRequest`, which expects real cookies and throws on a fake — and that throw would have become a refusal, so every anonymous visitor would have been served an empty roster on a shard whose guilds are public. Caught by the tests, not by reading. `team.overview` gets a live population reading beside core's stored one. Core's number comes from the last roster sync and is coarse by construction; this is the `presence.online` feed this module already holds. It is explicitly not a per-Team presence figure — the shard publishes a global aggregate and no per-guild breakdown exists on the wire, so claiming one would be inventing a number — and it renders nothing at all when it has nothing true to say. `team.member.row` is left unfilled. The useful thing to put there is a link to the character behind a row, and the props core can supply do not identify one: the member key and the site account id are withheld from every public roster. An empty cell beats a guess. Co-Authored-By: Claude <noreply@anthropic.com>
407 lines
18 KiB
JavaScript
407 lines
18 KiB
JavaScript
// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
|
||
//
|
||
// The tests that matter here are the REFUSALS. Core's contract is that module
|
||
// unavailability becomes staleness and never emptiness, and this module is the
|
||
// only thing that can honour it — an empty array from here is read as an
|
||
// authoritative "there are none", and core makes destructive decisions from an
|
||
// authoritative answer. Every state where this module cannot honestly claim to
|
||
// know is asserted below, because each one is a plausible place for someone to
|
||
// later "simplify" the guard away and get a plausible-looking empty list.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const core = require('../core')
|
||
|
||
// The provider reaches the database through core, which is initialised with a ctx
|
||
// in production. A minimal one is enough here — the db layer is stubbed anyway.
|
||
core.init({
|
||
db: { query: async () => [] },
|
||
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||
moduleId: 'uo',
|
||
})
|
||
|
||
const db = require('../model/teamProvider/teamProvider.db')
|
||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||
const uoLinkSocket = require('../utils/uoLinkSocket')
|
||
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||
const visibility = require('../utils/shardVisibility')
|
||
const provider = require('../model/teamProvider/teamProvider.model')
|
||
|
||
const saved = []
|
||
function patch(mod, name, fn) {
|
||
saved.push([mod, name, mod[name]])
|
||
mod[name] = fn
|
||
}
|
||
|
||
// The healthy default: configured, enabled, connected. Each test then breaks only
|
||
// the thing it is about.
|
||
function healthy() {
|
||
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
|
||
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
|
||
// An operator who has never run the client extraction — the default. The standard
|
||
// rank names must still resolve from the fallback table.
|
||
patch(clilocs, 'resolveMany', async () => new Map())
|
||
patch(db, 'listGuildLeaders', async () => [])
|
||
}
|
||
|
||
const guild = (extra = {}) => ({
|
||
id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
|
||
members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
|
||
})
|
||
|
||
const member = (extra = {}) => ({
|
||
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
|
||
rank: 1, rank_cliloc: 1062962, rank_name: null,
|
||
linked_user_id: null, is_online: 0, ...extra,
|
||
})
|
||
|
||
beforeEach(healthy)
|
||
afterEach(() => {
|
||
while (saved.length) {
|
||
const [mod, name, fn] = saved.pop()
|
||
mod[name] = fn
|
||
}
|
||
})
|
||
|
||
// ── The refusals ───────────────────────────────────────────────────────────
|
||
|
||
test('no uo-link configured refuses, on all three methods', async () => {
|
||
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
|
||
patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
|
||
|
||
for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
|
||
assert.equal(answer.ok, false)
|
||
assert.match(answer.reason, /no uo-link configured/)
|
||
assert.equal(answer.teams, undefined)
|
||
assert.equal(answer.members, undefined)
|
||
}
|
||
})
|
||
|
||
test('a disabled integration refuses rather than reporting a frozen board', async () => {
|
||
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
|
||
const answer = await provider.getTeams()
|
||
assert.equal(answer.ok, false)
|
||
assert.match(answer.reason, /disabled/)
|
||
})
|
||
|
||
test('a disconnected socket refuses, even though the board is still there', async () => {
|
||
// The tempting mistake, stated as a test: the board is durable and survives an
|
||
// outage, so serving it looks harmless. Core cannot tell a board five minutes
|
||
// stale from one five days stale, and it archives Teams and departs members
|
||
// from a complete answer.
|
||
patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
|
||
patch(db, 'listGuilds', async () => [guild()])
|
||
|
||
const answer = await provider.getTeams()
|
||
assert.equal(answer.ok, false)
|
||
assert.match(answer.reason, /not connected/)
|
||
assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
|
||
})
|
||
|
||
test('a database error refuses instead of throwing at core', async () => {
|
||
patch(db, 'listGuilds', async () => { throw new Error('table gone') })
|
||
const answer = await provider.getTeams()
|
||
assert.equal(answer.ok, false)
|
||
assert.match(answer.reason, /table gone/)
|
||
})
|
||
|
||
test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
|
||
patch(db, 'findGuild', async () => [])
|
||
const members = await provider.getTeamMembers('99')
|
||
assert.equal(members.ok, false)
|
||
assert.match(members.reason, /not on the board/)
|
||
|
||
const leaders = await provider.getTeamLeaders('99')
|
||
assert.equal(leaders.ok, false)
|
||
})
|
||
|
||
test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
|
||
// Protocol 4's roster arrives on its own frames, separately from the
|
||
// guild.update that creates the board row, so there is a real window where a
|
||
// 155-member guild has no roster rows. Reporting that as an empty roster would
|
||
// depart every member.
|
||
patch(db, 'findGuild', async () => [guild({ members: 155 })])
|
||
patch(db, 'listGuildMembers', async () => [])
|
||
|
||
const answer = await provider.getTeamMembers('1')
|
||
assert.equal(answer.ok, false)
|
||
assert.match(answer.reason, /has not arrived yet/)
|
||
assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
|
||
})
|
||
|
||
test('a guild the board says is genuinely empty reports an empty roster', async () => {
|
||
// The other side of the same coin: when the board itself says zero, an empty
|
||
// roster is the truth and withholding it would freeze a disbanding guild's
|
||
// membership forever.
|
||
patch(db, 'findGuild', async () => [guild({ members: 0 })])
|
||
patch(db, 'listGuildMembers', async () => [])
|
||
|
||
const answer = await provider.getTeamMembers('1')
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.members, [])
|
||
})
|
||
|
||
// ── The good answers ───────────────────────────────────────────────────────
|
||
|
||
test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
|
||
// The id survives a rename, which is what lets core apply its rename rule
|
||
// instead of seeing an unrelated new guild.
|
||
patch(db, 'listGuilds', async () => [guild()])
|
||
const answer = await provider.getTeams()
|
||
|
||
assert.equal(answer.ok, true)
|
||
assert.equal(answer.complete, true)
|
||
assert.deepEqual(answer.teams, [
|
||
{ externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
|
||
])
|
||
})
|
||
|
||
test('an alliance rides along as opaque meta', async () => {
|
||
patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
|
||
const { teams } = await provider.getTeams()
|
||
assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
|
||
})
|
||
|
||
test('the external id is a string, so core never compares a number to one', async () => {
|
||
patch(db, 'listGuilds', async () => [guild({ id: 42 })])
|
||
const { teams } = await provider.getTeams()
|
||
assert.equal(teams[0].externalId, '42')
|
||
})
|
||
|
||
test('a roster maps to the member shape core expects', async () => {
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
|
||
member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }),
|
||
])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.equal(members.length, 2)
|
||
assert.equal(members[0].memberKey, '0x1')
|
||
assert.equal(members[0].displayName, 'Aldric')
|
||
assert.equal(members[0].online, true)
|
||
assert.equal(members[0].leader, true, 'rank 4 is Leader')
|
||
assert.equal(members[1].leader, false)
|
||
assert.equal(members[1].online, false)
|
||
})
|
||
|
||
// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
|
||
|
||
test('several members can be leaders at once', async () => {
|
||
// The whole reason the wire grew a per-member rank: the board carries one
|
||
// leader_serial, so before this only a single leader could ever be reported.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', rank: 4 }),
|
||
member({ serial: '0x2', rank: 4 }),
|
||
member({ serial: '0x3', rank: 3 }),
|
||
])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
|
||
})
|
||
|
||
test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => {
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
|
||
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
|
||
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
|
||
})
|
||
|
||
test('the board’s leader is kept even when no roster row has rank yet', async () => {
|
||
// A shard whose roster has not been re-emitted since the amendment has no ranks
|
||
// stored. The founder-leader comes from a different frame and must not be lost
|
||
// by moving to ranks.
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
|
||
patch(db, 'listGuildLeaders', async () => [])
|
||
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
|
||
})
|
||
|
||
test('the board’s leader is not duplicated when they also hold rank 4', async () => {
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
|
||
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
|
||
const { leaders } = await provider.getTeamLeaders('1')
|
||
assert.equal(new Set(leaders).size, leaders.length)
|
||
})
|
||
|
||
test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
|
||
// The shard withholds the rank for a staff account, because ServUO's GuildRank
|
||
// getter reports Leader for anyone at GameMaster or above whatever their real
|
||
// rank. Reading the absence as leadership would republish exactly that lie.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.equal(members[0].leader, false)
|
||
assert.equal(members[0].rankLabel, null)
|
||
})
|
||
|
||
test('a standard rank resolves to its name without a cliloc table', async () => {
|
||
// The operator may never have run the client extraction, and a roster should
|
||
// still read "Warlord" rather than nothing.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
|
||
member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
|
||
member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
|
||
])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
|
||
})
|
||
|
||
test('the operator’s cliloc table wins over the built-in names', async () => {
|
||
// A localised or edited client should name the ranks, not this module's English
|
||
// fallback.
|
||
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
|
||
|
||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
|
||
})
|
||
|
||
test('a custom rank’s literal name beats both', async () => {
|
||
// A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
|
||
// and its own naming has to survive.
|
||
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
|
||
])
|
||
|
||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
|
||
})
|
||
|
||
test('a failing cliloc lookup falls back rather than failing the roster', async () => {
|
||
patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
|
||
|
||
const answer = await provider.getTeamMembers('1')
|
||
assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
|
||
assert.equal(answer.members[0].rankLabel, 'Warlord')
|
||
})
|
||
|
||
test('an unknown cliloc leaves the label null rather than inventing one', async () => {
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
|
||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null)
|
||
})
|
||
|
||
test('a member with no account at all is fine and unlinked', async () => {
|
||
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
|
||
// have no Account, and the local test world contains such mobiles.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.equal(members[0].userId, null)
|
||
})
|
||
|
||
test('userId comes from the roster’s web_id first, then the link table', async () => {
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
|
||
member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
|
||
member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
|
||
])
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
|
||
assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
|
||
assert.equal(members[2].userId, null)
|
||
})
|
||
|
||
test('web_id arrives as a string from the wire and is coerced', async () => {
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.equal(members[0].userId, 42)
|
||
assert.equal(typeof members[0].userId, 'number')
|
||
})
|
||
|
||
test('a guild with no leader anywhere reports none rather than guessing', async () => {
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
|
||
patch(db, 'listGuildLeaders', async () => [])
|
||
const answer = await provider.getTeamLeaders('1')
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.leaders, [])
|
||
})
|
||
|
||
test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
|
||
// Distinct from every refusal above: the socket is connected and the board is
|
||
// readable, so "no guilds" is a fact. Core still quarantines it before acting.
|
||
patch(db, 'listGuilds', async () => [])
|
||
const answer = await provider.getTeams()
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.teams, [])
|
||
})
|
||
|
||
// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
|
||
//
|
||
// The refusal semantics INVERT here and that is the point of these tests. For
|
||
// the three methods above, a refusal means "change nothing" and an empty array
|
||
// would be destructive. For this one, core fails CLOSED — a refusal withholds the
|
||
// roster — so the dangerous answer is the opposite: returning every key because
|
||
// the config could not be read would publish a roster an operator gated to staff.
|
||
|
||
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
|
||
|
||
function guilds(feature) {
|
||
patch(visibility, 'getConfig', async () => ({ guilds: feature }))
|
||
}
|
||
|
||
test('a viewer at or above the audience sees every row', async () => {
|
||
guilds({ enabled: true, audience: 'anonymous' })
|
||
const answer = await provider.projectRoster('1', rows, null)
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.members, ['0x1', '0x2'])
|
||
})
|
||
|
||
test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
|
||
// `ok: true` with an empty list is the correct answer here: this module KNOWS
|
||
// the viewer may see nothing. Core renders an empty roster rather than an
|
||
// error, which is what a gated shard is supposed to look like.
|
||
guilds({ enabled: true, audience: 'staff' })
|
||
const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.members, [])
|
||
})
|
||
|
||
test('an admin clears every audience', async () => {
|
||
guilds({ enabled: true, audience: 'admin' })
|
||
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
|
||
assert.deepEqual(answer.members, ['0x1', '0x2'])
|
||
})
|
||
|
||
test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
|
||
// The switch means "this shard does not publish guild data", not "publish it
|
||
// quietly to staff".
|
||
guilds({ enabled: false, audience: 'anonymous' })
|
||
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.members, [])
|
||
})
|
||
|
||
test('an unreadable visibility config REFUSES rather than publishing', async () => {
|
||
// The inversion, stated. Core reads this as "withhold", which is the only safe
|
||
// reading of "I could not work out who is allowed to look".
|
||
patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
|
||
const answer = await provider.projectRoster('1', rows, null)
|
||
assert.equal(answer.ok, false)
|
||
assert.match(answer.reason, /visibility could not be resolved/)
|
||
})
|
||
|
||
test('an absent viewer is anonymous, not an error', async () => {
|
||
guilds({ enabled: true, audience: 'logged_in' })
|
||
const answer = await provider.projectRoster('1', rows, null)
|
||
assert.equal(answer.ok, true)
|
||
assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
|
||
})
|
||
|
||
test('rows with no member key are dropped rather than answered as blanks', async () => {
|
||
guilds({ enabled: true, audience: 'anonymous' })
|
||
const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
|
||
assert.deepEqual(answer.members, ['0x1'])
|
||
})
|