// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ──────── // // Nothing here talks to Discord. `channel` is a fake that records what was sent, // and the assertions are about the three things this side genuinely owns: // // 1. **the channel comes from the app and is never looked up.** `newsAnnounce` // reads guild_config because there is one #news; a Team's destination is // per-Team configuration, and a bot that resolved it would hold a second // copy of a table it cannot see the inputs to; // 2. **a channel the bot cannot post to fails loudly rather than silently.** A // caller that is one-shot and best-effort only logs the difference, but an // operator debugging a quiet channel needs the bot's log to distinguish // "not connected" from "that id is not a text channel"; // 3. **Discord's own limits are enforced here.** An embed that exceeds them is // rejected WHOLESALE, so a long forum body must be truncated on this side // even though the app already excerpted it — the app's limit is a product // decision and this one is a protocol constraint. const { test } = require('node:test') const assert = require('node:assert/strict') const teamNotify = require('../src/discord/teamNotify') // A fake channel that records what it was sent. `isTextBased` is the one method // the code branches on, so it is the one worth making configurable. function fakeChannel({ textBased = true } = {}) { const sends = [] return { sends, isTextBased: () => textBased, send: async (payload) => { sends.push(payload); return { id: 'm1' } }, } } function fakeClient(channel, { throws = false } = {}) { return { channels: { fetch: async (id) => { if (throws) throw new Error('Unknown Channel') return id === 'chan-1' ? channel : null }, }, } } const post = (client, over = {}) => teamNotify.postTeamNotification(client, { channelId: 'chan-1', stream: 'team.forum.post', teamName: 'Blackthorn’s Legion', teamUrl: 'https://site/guilds/blackthorns-legion', title: 'Siege tonight', body: 'Meet at the moongate.', url: 'https://site/guilds/blackthorns-legion?thread=41', ...over, }) // ── 1. The channel is the app's decision ─────────────────────────────────── test('the message goes to the channel the app named', async () => { const channel = fakeChannel() await post(fakeClient(channel)) assert.equal(channel.sends.length, 1) const [embed] = channel.sends[0].embeds assert.equal(embed.data.title, 'Siege tonight') assert.equal(embed.data.author.name, 'Blackthorn’s Legion') assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41') }) test('no channel id at all is refused before anything is fetched', async () => { await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/) }) // ── 2. A channel the bot cannot use ──────────────────────────────────────── test('a channel the bot cannot see is a clear error, not a silent no-op', async () => { await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/) }) test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => { await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/) }) test('a voice channel is refused', async () => { await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/) }) // ── 3. Discord's limits, and the heading ─────────────────────────────────── test('an over-long title is truncated rather than rejected by Discord as a whole', async () => { const channel = fakeChannel() await post(fakeClient(channel), { title: 'y'.repeat(400) }) const [embed] = channel.sends[0].embeds assert.equal(embed.data.title.length, teamNotify.TITLE_MAX) assert.ok(embed.data.title.endsWith('…')) }) test('an over-long body is truncated to the description limit', async () => { const channel = fakeChannel() await post(fakeClient(channel), { body: 'z'.repeat(9000) }) const [embed] = channel.sends[0].embeds assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32) }) test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => { const channel = fakeChannel() await post(fakeClient(channel), { stream: 'team.announcement' }) const [embed] = channel.sends[0].embeds assert.match(embed.data.description, /^\*\*Announcement\*\*/) assert.match(embed.data.description, /Meet at the moongate\./) }) test('a roster event has no title, so the heading becomes the title', async () => { const channel = fakeChannel() await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' }) const [embed] = channel.sends[0].embeds assert.equal(embed.data.title, 'New member') assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one') }) test('an unknown stream still posts, under a neutral heading', async () => { const channel = fakeChannel() await post(fakeClient(channel), { stream: 'team.something.new', title: null }) const [embed] = channel.sends[0].embeds assert.equal(embed.data.title, 'Team update') }) test('a missing team name does not produce an embed with an empty author line', async () => { const channel = fakeChannel() await post(fakeClient(channel), { teamName: '', teamUrl: null }) const [embed] = channel.sends[0].embeds assert.equal(embed.data.author.name, 'A team') assert.equal(embed.data.author.url, undefined) }) test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => { assert.equal(teamNotify.clamp(' ', 100), null) assert.equal(teamNotify.clamp('ok', 100), 'ok') })