Found on the live rig, with the shard's guild feature gated to staff: the refusal still read "link your account — this shard shows guild information to linked players". Signing in reaches `logged_in` and linking a game account reaches `player`; `staff` and `admin` are roles an operator grants, and no amount of linking earns them. Inviting someone to do something that changes nothing is worse than plainly saying no. Also drops the host name from the list embed's title. `ctx.site` carries a base URL and no brand name, so naming the deployment there could only ever mean printing its hostname into a title on the shard's own Discord server. Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
6.6 KiB
JavaScript
149 lines
6.6 KiB
JavaScript
// `/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)
|
|
|
|
// Gated to staff: linking reaches `player` and stops there, so the invitation
|
|
// would be an instruction to do something that changes nothing. Found on the
|
|
// live rig, where a staff-gated shard still offered it.
|
|
stub({ audience: 'staff', level: 'anonymous' })
|
|
const unreachable = await command.handler({ options: {}, actor: anonymous })
|
|
assert.match(unreachable.text, /not shown to your account/)
|
|
assert.equal(unreachable.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.title, 'Guilds on this shard')
|
|
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)
|
|
})
|