// ── The bot's half of module slash commands (TEAMS.md §7.1) ──────────────── // // The first tests in this package, and they exist for a specific reason: phases // 8 and 9 put more of the Discord integration in this process, and the failure // modes here are ones no unit test in `server/` can see — a whole-set PUT that // one bad entry poisons, a deferral that has to happen before anything slow, and // a reply that must be EDITED rather than sent once the interaction is deferred. // // Nothing here talks to Discord. `interaction` is a fake that records what was // called on it, which is the whole of what this file is asserting about. const { test, beforeEach } = require('node:test') const assert = require('node:assert/strict') const dynamic = require('../src/discord/dynamicCommands') const appInternal = require('../src/site/appInternalClient') const staticCommands = require('../src/discord/commands') const originals = { fetchCommands: appInternal.fetchCommands, dispatchCommand: appInternal.dispatchCommand, get: staticCommands.get, } beforeEach(() => { dynamic._reset() Object.assign(appInternal, originals) staticCommands.get = originals.get }) const definition = (over = {}) => ({ name: 'guild', description: 'Show a guild', owner: 'uo', access: 'everyone', options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }], ...over, }) const answers = (commands, version = 1) => { appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } }) } function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) { const calls = [] return { calls, commandName, guildId: '999', user: { id: userId }, options: { get: (name) => (name in options ? { value: options[name] } : null), }, deferReply: async (payload) => calls.push(['defer', payload]), editReply: async (payload) => calls.push(['edit', payload]), followUp: async (payload) => calls.push(['followUp', payload]), } } // ── Pulling ──────────────────────────────────────────────────────────────── test('a pull reports whether the set moved, so a nudge is cheap', async () => { answers([definition()], 7) assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 }) // Same version, same size: nothing to re-register, and re-registering anyway // would mean a REST.put per module state change instead of per real change. assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 }) answers([definition()], 8) assert.equal((await dynamic.pull()).changed, true) }) // Otherwise a restart blip would deregister every module command from Discord // and re-register it a minute later, with members watching it happen. test('a failed pull keeps the set already registered', async () => { answers([definition()]) await dynamic.pull() appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' }) assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 }) assert.equal(dynamic.definitions().length, 1) }) // The collision the app cannot see: it validates against what IT registered and // does not know the bot's own array exists. Two entries of one name in a single // PUT is rejected as a batch, taking the built-ins down with it. test('a module command that collides with a built-in is dropped, not registered', async () => { staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined) answers([definition({ name: 'ping' }), definition()]) await dynamic.pull() assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild']) assert.equal(dynamic.has('ping'), false) }) test('definitions carry Discord’s numeric option types, not the contract’s names', async () => { answers([definition({ options: [ { name: 'who', type: 'user', description: 'A member', required: true }, { name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] }, ], })]) await dynamic.pull() const [data] = dynamic.definitions() assert.deepEqual(data.options.map((o) => o.type), [6, 4]) assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }]) assert.equal(data.default_member_permissions, undefined) }) // `linked` has no Discord equivalent — there is no "has a website account" // predicate — so only `staff` maps, and the app re-checks both regardless. test('only access: staff becomes a Discord permission default', async () => { answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })]) await dynamic.pull() const [staff, linked] = dynamic.definitions() assert.equal(typeof staff.default_member_permissions, 'string') assert.equal(linked.default_member_permissions, undefined) }) // ── Executing ────────────────────────────────────────────────────────────── test('the deferral happens before the dispatch, always', async () => { answers([definition()]) await dynamic.pull() let deferredFirst = false const interaction = fakeInteraction() appInternal.dispatchCommand = async () => { deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer' return { ok: true, data: { ok: true, response: { text: 'hi' } } } } await dynamic.execute(interaction) assert.ok(deferredFirst, 'the website is never in Discord’s 3-second ack path') assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }]) }) test('the options the member supplied are passed by name, as plain values', async () => { answers([definition({ options: [ { name: 'name', type: 'string', description: 'd' }, { name: 'who', type: 'user', description: 'd' }, { name: 'missing', type: 'string', description: 'd' }, ], })]) await dynamic.pull() let sent = null appInternal.dispatchCommand = async (body) => { sent = body return { ok: true, data: { ok: true, response: {} } } } await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } })) assert.deepEqual(sent.options, { name: 'KOC', who: '42' }) assert.equal(sent.platformUserId, '555') assert.equal(sent.guildId, '999') }) test('a title or fields render as an embed; a bare text does not', async () => { answers([definition()]) await dynamic.pull() appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } }, }) const interaction = fakeInteraction() await dynamic.execute(interaction) const [, payload] = interaction.calls.at(-1) assert.equal(payload.embeds[0].title, 'Knights') assert.equal(payload.embeds[0].description, 'Alliance: Accord') assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7') }) // §9 answer 5: the public projection, plus a private nudge to link. One reply // cannot be both, so the aside is a follow-up — which is the bot's decision to // make, not the handler's. test('a notice becomes an ephemeral follow-up beside a public answer', async () => { answers([definition()]) await dynamic.pull() appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: true, response: { text: 'public', notice: 'Link your account' } }, }) const interaction = fakeInteraction() await dynamic.execute(interaction) assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }]) }) test('a notice is not repeated when the answer was already private', async () => { answers([definition({ access: 'linked' })]) await dynamic.pull() appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: true, response: { text: 'private', notice: 'Link your account' } }, }) const interaction = fakeInteraction() await dynamic.execute(interaction) assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }]) assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false) }) // Every failure path EDITS. Replying to a deferred interaction throws, so a // refusal that used reply() would turn a clean "no" into an unhandled error. test('a refusal is phrased by the bot and edited into the deferred reply', async () => { answers([definition({ access: 'linked' })]) await dynamic.pull() appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false }, }) const interaction = fakeInteraction() await dynamic.execute(interaction) assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/) assert.equal(interaction.calls.filter(([kind]) => kind === 'edit').length, 1) }) test('an unreachable app is the same sentence to the member and a different line in the log', async () => { answers([definition()]) await dynamic.pull() appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' }) const interaction = fakeInteraction() await dynamic.execute(interaction) assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/) }) test('an interaction for a command the app no longer serves is left alone', async () => { answers([definition()]) await dynamic.pull() const interaction = fakeInteraction({ commandName: 'gone' }) assert.equal(await dynamic.execute(interaction), false) assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours') })