// ── The Team provider, with no core and no database ─────────────────────── // // The provider is the one part of a module that CORE calls, which makes it the // one part whose failures reach further than its own pages: a wrong answer here // is not a broken screen, it is core archiving Teams or departing members on your // authority. So it gets the most tests in the template, and they are mostly about // what it says when things are wrong. // // Everything is stubbed at the `.db.js` seam, the same way `worldStatus.test.js` // does it. There is no database and no `ctx` — the provider only reaches core for // its logger, and the one path that logs is exercised by installing a fake `ctx`. const test = require('node:test') const assert = require('node:assert') const core = require('../core') const db = require('../model/clans/clanProvider.db') const settings = require('../model/clans/clanSettings') const worldStatus = require('../model/worldStatus/worldStatus.model') const provider = require('../model/clans/clanProvider.model') const { fakeCtx } = require('./_fakes') const CLAN = { externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 2 } const ROSTER = [ { memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', isLeader: 1, isOnline: 1, userId: 7 }, { memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', isLeader: 0, isOnline: 0, userId: null }, ] /** Swap out the db seam and the world-status read for one test. */ function withGame({ online = true, stale = false, clan = CLAN, roster = ROSTER, throws = null }, fn) { const real = { getPublicStatus: worldStatus.getPublicStatus, findClan: db.findClan, listClans: db.listClans, listMembers: db.listMembers, } core._reset() core.init(fakeCtx()) worldStatus.getPublicStatus = async () => ({ online, stale, players: 0, worldName: 'Example World', updatedAt: null }) db.findClan = async () => { if (throws) throw new Error(throws); return clan } db.listClans = async () => { if (throws) throw new Error(throws); return clan ? [clan] : [] } db.listMembers = async () => { if (throws) throw new Error(throws); return roster } return Promise.resolve(fn()).finally(() => { Object.assign(worldStatus, { getPublicStatus: real.getPublicStatus }) Object.assign(db, { findClan: real.findClan, listClans: real.listClans, listMembers: real.listMembers }) core._reset() }) } test('getTeams answers an envelope, not an array', () => withGame({}, async () => { const answer = await provider.getTeams() assert.strictEqual(answer.ok, true) assert.strictEqual(answer.complete, true) assert.strictEqual(answer.teams[0].externalId, 'clan-1') // A bare array has exactly one shape for "I cannot answer" — `[]` — and it is // the same shape as "there are none". The envelope exists to keep those two // apart, so the array must never be the return value itself. assert.ok(!Array.isArray(answer)) })) test('an unreachable game REFUSES rather than reporting no clans', () => withGame({ online: false }, async () => { // The most important assertion in this file. `{ ok: true, teams: [] }` reads // as an authoritative "this deployment has no clans", and core acts on // authoritative answers: it archives the Teams that are missing from one. A // cold start would empty the site. for (const answer of [ await provider.getTeams(), await provider.getTeamMembers('clan-1'), await provider.getTeamLeaders('clan-1'), ]) { assert.strictEqual(answer.ok, false) assert.ok(answer.reason, 'a refusal without a reason is what an operator has to debug from') assert.strictEqual(answer.teams, undefined) } })) test('stale data refuses too, even though the rows are readable', () => withGame({ online: true, stale: true }, async () => { // The tables still hold a perfectly good snapshot, which is what makes this // tempting to get wrong. Core cannot tell a snapshot five minutes old from one // five days old, so an answer it would act on must be current. assert.strictEqual((await provider.getTeams()).ok, false) })) test('a database error is caught and becomes a refusal', () => withGame({ throws: 'connection lost' }, async () => { // Core reads a rejected promise as a refusal anyway. Catching it is what puts // the module's own name on the log line, instead of an operator seeing core // blamed for a fault in a module. const answer = await provider.getTeams() assert.strictEqual(answer.ok, false) assert.match(answer.reason, /connection lost/) })) test('an empty roster is refused when the game says the clan is not empty', () => withGame({ roster: [] }, async () => { // The clan row and the roster arrive on separate frames in any real ingest, so // there is a window where this module knows a clan exists and not who is in // it. Answering "nobody" there would have core depart every member. const answer = await provider.getTeamMembers('clan-1') assert.strictEqual(answer.ok, false) assert.match(answer.reason, /has not arrived/) })) test('a genuinely empty clan is answered, not refused', () => withGame({ clan: { ...CLAN, memberCount: 0 }, roster: [] }, async () => { // The other half of the rule above, and the reason `member_count` is in the // schema at all: without a count from the game there is no way to tell these // two cases apart, and a provider that refuses both can never report a clan // emptying. const answer = await provider.getTeamMembers('clan-1') assert.strictEqual(answer.ok, true) assert.deepStrictEqual(answer.members, []) })) test('members carry the contract shape, with userId resolved by this module', () => withGame({}, async () => { const { members } = await provider.getTeamMembers('clan-1') assert.deepStrictEqual(members[0], { memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', leader: true, online: true, userId: 7, }) // Not linked to a site account is the ordinary case and must be `null` rather // than absent or `0`: core stores it, and `0` is a user id. assert.strictEqual(members[1].userId, null) })) test('getTeamLeaders answers keys, plurally', () => withGame({ roster: [...ROSTER, { ...ROSTER[0], memberKey: 'char-003', isLeader: 1 }] }, async () => { const answer = await provider.getTeamLeaders('clan-1') assert.deepStrictEqual(answer.leaders, ['char-001', 'char-003']) // Core grants forum moderation and Team management from this list, so a // provider that can only name one leader locks the others out of their own // clan. assert.ok(answer.leaders.length > 1) })) test('projectRoster returns member keys the caller supplied, in core’s snake_case', () => withGame({}, async () => { // Core hands back the rows as IT stores them — this is the module's own data // coming home — so the key is `member_key` and not the `memberKey` the // provider sent out. Reading the wrong one silently answers with a list of // `undefined`, which core filters to nothing: an empty roster with `ok: true`. const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null) assert.deepStrictEqual(answer, { ok: true, members: ['char-001'] }) })) test('projectRoster fails CLOSED when it cannot resolve the question', () => withGame({}, async () => { // The asymmetry that matters. The other three methods refuse and core keeps // what it has; this one refuses and core serves an EMPTY roster, because for a // visibility question "keep what you have" means publishing it. So a provider // that cannot answer must say so rather than falling back to "show everything". const real = settings.getRosterAudience settings.getRosterAudience = async () => { throw new Error('settings unreadable') } try { const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null) assert.strictEqual(answer.ok, false) // Not `{ ok: true, members: [...everything] }`, which is the tempting // fallback — the rows are right there and the lookup is the only thing that // failed. That publishes a roster an operator may have gated to staff. assert.strictEqual(answer.members, undefined) } finally { settings.getRosterAudience = real } })) test('a members-only audience withholds from an anonymous viewer and answers for one inside', () => withGame({}, async () => { const real = settings.getRosterAudience settings.getRosterAudience = async () => 'members' try { const rows = [{ member_key: 'char-001' }, { member_key: 'char-002' }] // Anonymous is an ANSWER — `{ ok: true }` with nothing visible — and not a // refusal. A provider that refuses here tells core its rule broke, and core // reports the roster as unavailable rather than as private. const anon = await provider.projectRoster('clan-1', rows, null) assert.deepStrictEqual(anon, { ok: true, members: [] }) // Aldric's account, resolved from this module's own roster — the only place // the game↔site mapping exists. const inside = await provider.projectRoster('clan-1', rows, { userId: 7, role: 'user' }) assert.deepStrictEqual(inside.members, ['char-001', 'char-002']) // All or none. The audience is a property of the FEATURE, not of a member; // there is no configuration in which half a roster is public. const outside = await provider.projectRoster('clan-1', rows, { userId: 99, role: 'user' }) assert.deepStrictEqual(outside.members, []) } finally { settings.getRosterAudience = real } })) test('pageUrlTemplate is a relative path carrying the substitution core makes', () => { // Core substitutes `{externalId}` and does nothing else with it. A template // naming its own host is refused at registration — there is no reason for a // module to redirect the site's outbound mail — and so is a protocol-relative // `//host/x`. assert.match(provider.pageUrlTemplate, /^\/[^/]/) assert.ok(provider.pageUrlTemplate.includes('{externalId}')) })