feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the definition and runs no line of this module. `/guild` and not `/team`, deliberately. Core does not own the word for a Team — that is what deleted its Team pages in phase 3 — so it does not publish the noun in a channel either. Core ships the dispatcher and zero commands. The audience rungs are re-resolved in the handler rather than assumed: a shard that gates guilds to staff does not become public because the question arrived over Discord. The provider's own staleness guard is honoured too, so a stale board answers "not connected" instead of reporting what it still holds, and `resolveUserId` is exported rather than copied so "linked" means here what it means on the roster. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
139
server/test/guildCommand.test.js
Normal file
139
server/test/guildCommand.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// `/guild` — the chat command registered through `api.registerSlashCommands`
|
||||
// (TEAMS.md §7.1, MODULE_API 1.6.0).
|
||||
//
|
||||
// The properties worth pinning are all about the ANSWER being the same answer
|
||||
// the website gives, because that is the whole risk of a second surface: the
|
||||
// audience rungs are re-resolved here rather than assumed, the shard's own
|
||||
// offline guard is honoured, and the link prompt appears only when linking would
|
||||
// actually change what the caller is told.
|
||||
|
||||
const { test, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const command = require('../commands/guild.command')
|
||||
const db = require('../model/teamProvider/teamProvider.db')
|
||||
const provider = require('../model/teamProvider/teamProvider.model')
|
||||
const visibility = require('../utils/shardVisibility')
|
||||
|
||||
const originals = {
|
||||
getConfig: visibility.getConfig,
|
||||
viewerLevel: visibility.viewerLevel,
|
||||
boardIsCurrent: provider.boardIsCurrent,
|
||||
listGuilds: db.listGuilds,
|
||||
listGuildMembers: db.listGuildMembers,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
visibility.getConfig = originals.getConfig
|
||||
visibility.viewerLevel = originals.viewerLevel
|
||||
provider.boardIsCurrent = originals.boardIsCurrent
|
||||
db.listGuilds = originals.listGuilds
|
||||
db.listGuildMembers = originals.listGuildMembers
|
||||
})
|
||||
|
||||
const GUILDS = [
|
||||
{ id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
|
||||
{ id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
|
||||
]
|
||||
|
||||
const MEMBERS = [
|
||||
{ serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
|
||||
{ serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
|
||||
{ serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
|
||||
]
|
||||
|
||||
function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
|
||||
visibility.getConfig = async () => ({ guilds: { enabled, audience } })
|
||||
visibility.viewerLevel = async () => level
|
||||
provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
|
||||
db.listGuilds = async () => GUILDS
|
||||
db.listGuildMembers = async () => MEMBERS
|
||||
}
|
||||
|
||||
const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
|
||||
const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
|
||||
|
||||
test('the definition stays inside the option schema §7.1.1 allows', () => {
|
||||
assert.equal(command.name, 'guild')
|
||||
assert.equal(command.access, 'everyone')
|
||||
for (const option of command.options) {
|
||||
assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
|
||||
assert.ok(option.description.length <= 100)
|
||||
}
|
||||
})
|
||||
|
||||
test('the guilds feature being off withholds everything, staff included', async () => {
|
||||
stub({ enabled: false, level: 'admin' })
|
||||
const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
|
||||
assert.match(res.text, /does not publish guild information/)
|
||||
assert.equal(res.ephemeral, true)
|
||||
})
|
||||
|
||||
// The reason this command is not a thin wrapper over a public route: a rung
|
||||
// below the feature's audience must be refused HERE, or a shard that gates
|
||||
// guilds to staff would publish them to a Discord channel.
|
||||
test('a caller below the feature audience is refused', async () => {
|
||||
stub({ audience: 'staff', level: 'anonymous' })
|
||||
const res = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(res.text, /not shown to your account/)
|
||||
assert.equal(res.ephemeral, true)
|
||||
})
|
||||
|
||||
test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
|
||||
stub({ audience: 'player', level: 'anonymous' })
|
||||
const gated = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(gated.notice, /Link your account/)
|
||||
|
||||
// Public guilds: there is nothing more to see, so there is nothing to prompt.
|
||||
stub({ audience: 'anonymous', level: 'anonymous' })
|
||||
const open = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.equal(open.notice, null)
|
||||
})
|
||||
|
||||
test('a stale board answers offline rather than reporting what it still holds', async () => {
|
||||
stub({ current: false })
|
||||
const res = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(res.text, /not connected right now/)
|
||||
})
|
||||
|
||||
test('no argument lists the largest guilds', async () => {
|
||||
stub()
|
||||
const res = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.equal(res.fields.length, 2)
|
||||
assert.match(res.fields[0].name, /Knights of the Codex/)
|
||||
assert.match(res.fields[0].value, /12 members · 3 online/)
|
||||
})
|
||||
|
||||
test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
|
||||
stub()
|
||||
const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
|
||||
assert.match(byAbbr.title, /Knights of the Codex/)
|
||||
|
||||
const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
|
||||
assert.match(exact.title, /Hospitaller/)
|
||||
|
||||
// "knights" hits both, and answering with either would be worse than asking.
|
||||
const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
|
||||
assert.match(ambiguous.text, /Several guilds match/)
|
||||
assert.equal(ambiguous.ephemeral, true)
|
||||
})
|
||||
|
||||
test('a miss is an answer, not a failure', async () => {
|
||||
stub()
|
||||
const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
|
||||
assert.match(res.text, /No guild matches/)
|
||||
})
|
||||
|
||||
// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
|
||||
// the link table — because that is what "linked" means everywhere else here.
|
||||
test('the detail carries the counts, the leaders and a link to the module page', async () => {
|
||||
stub({ level: 'player' })
|
||||
const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
|
||||
const field = (name) => res.fields.find((f) => f.name === name).value
|
||||
assert.equal(field('Members'), '12')
|
||||
assert.equal(field('Online'), '3')
|
||||
assert.equal(field('Linked accounts'), '2')
|
||||
assert.equal(field('Leaders'), 'Dain, Elowen')
|
||||
assert.match(res.url, /\/uo\/guilds\/7$/)
|
||||
assert.equal(res.notice, null)
|
||||
})
|
||||
Reference in New Issue
Block a user