// 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 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, []) })