// The roster read and its audience projection (docs/website/TEAMS.md §3.2, §3.3). // // Two questions meet here and the file exists to keep them apart: // // WHICH ROWS is the module's — it owns the visibility framework and its rung // configuration, and core does not know what a rung is. // WHAT A ROW is core's — the member key and the user id are never published, // LOOKS LIKE whatever the module answers. // // The dangerous simplification is to let the module return rows instead of keys: // core's field guarantee would then rest on every module's good behaviour rather // than on core, and one module re-adding a `userId` would publish site accounts // against in-game characters on a public page. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const teamsDb = require('../src/model/teams/teams.db') const access = require('../src/model/teams/teamAccess.model') const teamProvider = require('../src/model/teams/teamProvider') const teamSync = require('../src/model/teams/teamSync.model') const teams = require('../src/model/teams/teams.model') const saved = new Map() function patch(mod, name, fn) { if (!saved.has(mod)) saved.set(mod, new Map()) if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) mod[name] = fn } function restore() { for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn saved.clear() } const ROWS = [ { member_key: '0x1', display_name: 'Aldric', user_id: 7, is_leader: 1, rank_label: 'Leader', online: 1 }, { member_key: '0x2', display_name: 'Brenna', user_id: null, is_leader: 0, rank_label: null, online: 0 }, { member_key: '0x3', display_name: 'Cadfael', user_id: 9, is_leader: 0, rank_label: null, online: 0 }, ] beforeEach(() => { patch(teamsDb, 'findBySlug', async (slug) => (slug === 'the-guild' ? { id: 1, external_id: 'g1', slug, hidden: 0, status: 'active', roster_synced_at: null } : undefined)) patch(access, 'rosterWithOverrides', async () => ROWS.map((r) => ({ ...r }))) // syncStatus() reads sync state and the poll interval; neither is what this // file is about, and both would otherwise reach the pool. patch(teamProvider, 'providerModuleId', () => null) patch(teamSync, 'intervalSeconds', async () => 900) }) afterEach(restore) test('with no module projecting, the whole roster is served at core\'s public shape', async () => { patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: false, reason: 'no provider' })) const roster = await teams.rosterPublic('the-guild', null) assert.equal(roster.members.length, 3) assert.equal(roster.projected, false) assert.equal(roster.projectionUnavailable, undefined, 'nothing was withheld, so nothing to report') }) test('the module chooses which rows a viewer sees', async () => { patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x2'] })) const roster = await teams.rosterPublic('the-guild', null) assert.deepEqual(roster.members.map((m) => m.displayName), ['Brenna']) assert.equal(roster.projected, true) }) test('a module that projects but cannot answer withholds the roster — it does not serve it', async () => { // The whole point. "Leave it alone" is right for a roster SYNC and wrong for a // visibility question: it would publish exactly what the rungs withhold. patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: true, reason: 'sidecar down' })) const roster = await teams.rosterPublic('the-guild', null) assert.deepEqual(roster.members, []) assert.equal(roster.projected, false) assert.equal(roster.projectionUnavailable, true, 'an empty roster must be distinguishable from a silent one') }) test('the module cannot widen the published fields, only narrow the rows', async () => { // A module answering with keys it was given still yields core's shape. There is // no answer it can give that puts a member key or a user id on a public page. patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0x2', '0x3'] })) const roster = await teams.rosterPublic('the-guild', null) for (const member of roster.members) { assert.deepEqual( Object.keys(member).sort(), ['displayName', 'isLeader', 'linked', 'online', 'rankLabel'], 'the public member shape is core\'s and is closed', ) } assert.deepEqual(roster.members.map((m) => m.linked), [true, false, true]) }) test('a key the module invents matches nothing rather than adding a row', async () => { patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0xNOPE'] })) const roster = await teams.rosterPublic('the-guild', null) assert.equal(roster.members.length, 1) }) test('the viewer is described to the module, not handed over', async () => { let seen patch(teamProvider, 'projectRoster', async (externalId, members, viewer) => { seen = viewer return { ok: true, members: members.map((m) => m.member_key) } }) await teams.rosterPublic('the-guild', { userId: 7, role: 'player' }) assert.deepEqual(seen, { userId: 7, role: 'player' }) }) test('an unknown slug is not found, and the module is never consulted about it', async () => { let called = false patch(teamProvider, 'projectRoster', async () => { called = true; return { ok: true, members: [] } }) assert.equal(await teams.rosterPublic('no-such-team', null), null) assert.equal(called, false) }) test('a hidden team\'s roster does not answer publicly at all', async () => { patch(teamsDb, 'findBySlug', async () => ({ id: 1, external_id: 'g1', slug: 'x', hidden: 1, status: 'active' })) patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1'] })) assert.equal(await teams.rosterPublic('x', null), null) })