// 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 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 })) } 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, 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', is_online: 1 }), member({ serial: '0x2', name: 'Bree', acct: null, 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, 'matches the board’s leader_serial') assert.equal(members[1].leader, false) assert.equal(members[1].online, false) }) test('rankLabel is null, honestly — the wire carries no guild rank', async () => { // The roster member is the standard actor object (serial, name, player, acct?, // webId?). Inventing a rank from the leader flag would be core displaying a // label this module made up. patch(db, 'findGuild', async () => [guild()]) patch(db, 'listGuildMembers', async () => [member()]) const { members } = await provider.getTeamMembers('1') assert.equal(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('leadership is the board’s single leader — one, and honestly one', async () => { // TEAMS.md §2.5 expects multiple leaders (GuildRank.Rank >= 4) and core // supports them, but Protocol 4 does not put rank on the wire. Raising this to // the full set is a protocol change, not something this file can fix. patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })]) assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1']) }) test('a guild with no leader on the board reports none rather than guessing', async () => { patch(db, 'findGuild', async () => [guild({ leader_serial: null })]) 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, []) })