Files
website/bot/test/dynamicCommands.test.js
Claude aca4d23179
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / bot-tests (pull_request) Successful in 8m50s
fix(teams): a private answer has to be private, and the deferral decides that
Found on the live rig. Ephemerality is a property of the DEFERRAL, which happens
before the handler has said anything — so the envelope's `ephemeral` was being
read and then ignored, and `/guild`'s "not shown to your account" refusal was
posted into the channel, announcing a member's access level to everyone in it.

When the handler wants privacy the deferral did not give it, the deferred reply
is now withdrawn and the answer arrives as an ephemeral follow-up. The
interaction token stays valid, so this is a supported path and not a trick; the
cost is a "thinking..." that appears and vanishes. There is no reverse case — a
command deferred privately must not become public because a handler omitted a
flag — and a refusal is always private whatever the command's usual privacy.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:12 -05:00

270 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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]),
deleteReply: async () => calls.push(['delete']),
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 Discords numeric option types, not the contracts 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 Discords 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.
// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
// said anything — so honouring a per-answer flag needs the deferred reply
// withdrawn. The live walk caught the version that ignored it posting "guild
// information is not shown to your account" into the channel, which announces a
// member's access level to everyone in it.
test('a handler asking for privacy gets it, even though the deferral was public', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
})
test('an already-private deferral just edits — no second message', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
})
// "You do not have access to that" is about one member and belongs to one
// member, whatever the command's usual privacy.
test('a refusal is always private', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
})
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/)
// Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
// withdrawal — replying twice to a deferred interaction is what throws.
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
})
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/)
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
})
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')
})