diff --git a/migrations/004_role_menu_templates.cjs b/migrations/004_role_menu_templates.cjs new file mode 100644 index 00000000..ec127a09 --- /dev/null +++ b/migrations/004_role_menu_templates.cjs @@ -0,0 +1,49 @@ +/** + * Migration: Role Menu Templates + * + * Stores reusable role menu templates — both built-in (is_builtin=true) and + * custom guild-created templates. Shared templates (is_shared=true) are + * visible to every guild. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/135 + */ + +'use strict'; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS role_menu_templates ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'custom', + created_by_guild_id TEXT, + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + is_shared BOOLEAN NOT NULL DEFAULT FALSE, + options JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + pgm.sql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_rmt_name_guild + ON role_menu_templates (LOWER(name), COALESCE(created_by_guild_id, '__builtin__')) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_rmt_guild + ON role_menu_templates (created_by_guild_id) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_rmt_shared + ON role_menu_templates (is_shared) WHERE is_shared = TRUE + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS role_menu_templates CASCADE'); +}; diff --git a/src/commands/rolemenu.js b/src/commands/rolemenu.js new file mode 100644 index 00000000..7ac8df02 --- /dev/null +++ b/src/commands/rolemenu.js @@ -0,0 +1,357 @@ +/** + * Role Menu Command + * + * Manage reusable role menu templates. + * + * Subcommands (all under /rolemenu template): + * list — list available templates + * info — show template details + * apply [merge] — apply template to this guild's role menu config + * create — create a custom template (JSON options array) + * delete — delete a custom template + * share — toggle sharing of a custom template with other guilds + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/135 + */ + +import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { info } from '../logger.js'; +import { getConfig, setConfigValue } from '../modules/config.js'; +import { + applyTemplateToOptions, + createTemplate, + deleteTemplate, + getTemplateByName, + listTemplates, + setTemplateShared, + validateTemplateName, + validateTemplateOptions, +} from '../modules/roleMenuTemplates.js'; +import { isModerator } from '../utils/permissions.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const adminOnly = true; + +export const data = new SlashCommandBuilder() + .setName('rolemenu') + .setDescription('Manage role menu templates') + .addSubcommandGroup((group) => + group + .setName('template') + .setDescription('Role menu template operations') + .addSubcommand((sub) => + sub.setName('list').setDescription('List available role menu templates'), + ) + .addSubcommand((sub) => + sub + .setName('info') + .setDescription('Show details for a template') + .addStringOption((opt) => + opt.setName('name').setDescription('Template name').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('apply') + .setDescription("Apply a template to this guild's role menu config") + .addStringOption((opt) => + opt.setName('name').setDescription('Template name').setRequired(true), + ) + .addBooleanOption((opt) => + opt + .setName('merge') + .setDescription( + 'Merge with existing role menu options instead of replacing (default: replace)', + ) + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('create') + .setDescription('Create a custom role menu template for this guild') + .addStringOption((opt) => + opt.setName('name').setDescription('Template name').setRequired(true), + ) + .addStringOption((opt) => + opt + .setName('options') + .setDescription( + 'JSON array: [{"label":"Red","description":"Red role","roleId":"123"}]', + ) + .setRequired(true), + ) + .addStringOption((opt) => + opt + .setName('description') + .setDescription('Short description of this template') + .setRequired(false), + ) + .addStringOption((opt) => + opt + .setName('category') + .setDescription('Category (e.g. colors, pronouns, notifications, custom)') + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('delete') + .setDescription('Delete a custom template owned by this guild') + .addStringOption((opt) => + opt.setName('name').setDescription('Template name').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('share') + .setDescription('Toggle sharing of a guild-owned template with other guilds') + .addStringOption((opt) => + opt.setName('name').setDescription('Template name').setRequired(true), + ) + .addBooleanOption((opt) => + opt.setName('enabled').setDescription('Share this template?').setRequired(true), + ), + ), + ); + +// ── Permission guard ────────────────────────────────────────────────────────── + +function hasModeratorPerms(interaction, guildConfig) { + return ( + interaction.member.permissions.has(PermissionFlagsBits.Administrator) || + isModerator(interaction.member, guildConfig) + ); +} + +// ── Subcommand handlers ─────────────────────────────────────────────────────── + +async function handleList(interaction) { + const templates = await listTemplates(interaction.guildId); + if (templates.length === 0) { + await safeEditReply(interaction, { + content: '📋 No templates available. Use `/rolemenu template create` to make one.', + }); + return; + } + + const byCategory = {}; + for (const tpl of templates) { + const cat = tpl.category || 'custom'; + byCategory[cat] = byCategory[cat] || []; + byCategory[cat].push(tpl); + } + + const embed = new EmbedBuilder() + .setTitle('📋 Role Menu Templates') + .setColor(0x5865f2) + .setFooter({ text: `${templates.length} template(s) available` }); + + for (const [cat, items] of Object.entries(byCategory)) { + const lines = items.map((t) => { + const badges = [t.is_builtin ? '🔧 built-in' : '🏠 custom', t.is_shared ? '🌐 shared' : null] + .filter(Boolean) + .join(' · '); + return `**${t.name}** — ${t.description || 'no description'} *(${badges})*`; + }); + const fieldValue = lines.join('\n').slice(0, 1020) + (lines.join('\n').length > 1020 ? '...' : ''); + embed.addFields({ name: `📂 ${cat}`, value: fieldValue, inline: false }); + } + + await safeEditReply(interaction, { embeds: [embed] }); +} + +async function handleInfo(interaction) { + const name = interaction.options.getString('name'); + const tpl = await getTemplateByName(interaction.guildId, name); + if (!tpl) { + await safeEditReply(interaction, { content: `❌ Template \`${name}\` not found.` }); + return; + } + + const options = Array.isArray(tpl.options) ? tpl.options : []; + const optLines = + options + .map( + (opt, i) => + `${i + 1}. **${opt.label}**${opt.description ? ` — ${opt.description}` : ''}${opt.roleId ? ` <@&${opt.roleId}>` : ''}`, + ) + .join('\n') || '_No options_'; + + const embed = new EmbedBuilder() + .setTitle(`🎭 Template: ${tpl.name}`) + .setColor(0x57f287) + .setDescription(tpl.description || '_No description_') + .addFields( + { name: 'Category', value: tpl.category || 'custom', inline: true }, + { name: 'Type', value: tpl.is_builtin ? '🔧 Built-in' : '🏠 Custom', inline: true }, + { name: 'Shared', value: tpl.is_shared ? '✅ Yes' : '❌ No', inline: true }, + { name: `Options (${options.length})`, value: optLines.slice(0, 1024), inline: false }, + ) + .setFooter({ + text: `Created: ${tpl.created_at ? new Date(tpl.created_at).toDateString() : 'N/A'}`, + }); + + await safeEditReply(interaction, { embeds: [embed] }); +} + +async function handleApply(interaction) { + const name = interaction.options.getString('name'); + const merge = interaction.options.getBoolean('merge') ?? false; + + const tpl = await getTemplateByName(interaction.guildId, name); + if (!tpl) { + await safeEditReply(interaction, { content: `❌ Template \`${name}\` not found.` }); + return; + } + + const guildConfig = getConfig(interaction.guildId); + const existingOptions = merge ? (guildConfig?.welcome?.roleMenu?.options ?? []) : []; + const newOptions = applyTemplateToOptions(tpl, existingOptions); + + // Filter out options with empty roleIds - Discord rejects empty select values + const validOptions = newOptions.filter(opt => opt.roleId && opt.roleId.trim()); + const hasInvalidOptions = validOptions.length !== newOptions.length; + + // Only enable role menu for non-built-in templates with valid roleIds + const shouldEnable = !tpl.is_builtin && validOptions.length > 0; + await setConfigValue('welcome.roleMenu.enabled', shouldEnable, interaction.guildId); + await setConfigValue('welcome.roleMenu.options', validOptions, interaction.guildId); + + info('Role menu template applied', { + guildId: interaction.guildId, + template: tpl.name, + optionCount: validOptions.length, + merge, + userId: interaction.user.id, + }); + + const builtinNote = tpl.is_builtin + ? '\n\n> ⚠️ Built-in templates have no role IDs. Use the config editor to assign a **roleId** to each option before posting the role menu.' + : ''; + + const filterNote = hasInvalidOptions + ? '\n\n⚠️ Some options had empty roleIds and were filtered out. Add roleIds in the config editor before posting.' + : ''; + + await safeEditReply(interaction, { + content: `✅ Applied template **${tpl.name}** to role menu config (${validOptions.length} option${validOptions.length !== 1 ? 's' : ''}).${merge ? ' Merged with existing options.' : ''}${builtinNote}${filterNote}\n\nRun \`/welcome setup\` to post the updated role menu.`, + }); +} + +async function handleCreate(interaction) { + const name = interaction.options.getString('name'); + const optionsRaw = interaction.options.getString('options'); + const description = interaction.options.getString('description') ?? ''; + const category = interaction.options.getString('category') ?? 'custom'; + + const nameErr = validateTemplateName(name); + if (nameErr) { + await safeEditReply(interaction, { content: `❌ ${nameErr}` }); + return; + } + + let parsedOptions; + try { + parsedOptions = JSON.parse(optionsRaw); + } catch { + await safeEditReply(interaction, { + content: + '❌ Options must be valid JSON. Example:\n```json\n[{"label":"Red","description":"Red role","roleId":"123456789"}]\n```', + }); + return; + } + + const optErr = validateTemplateOptions(parsedOptions); + if (optErr) { + await safeEditReply(interaction, { content: `❌ ${optErr}` }); + return; + } + + try { + const tpl = await createTemplate({ + guildId: interaction.guildId, + name, + description, + category, + options: parsedOptions, + }); + await safeEditReply(interaction, { + content: `✅ Template **${tpl.name}** created with ${parsedOptions.length} option(s). Use \`/rolemenu template apply ${tpl.name}\` to apply it.`, + }); + } catch (err) { + if (err.code === '23505') { + await safeEditReply(interaction, { + content: `❌ A template named **${name}** already exists for this guild.`, + }); + } else { + throw err; + } + } +} + +async function handleDelete(interaction) { + const name = interaction.options.getString('name'); + const deleted = await deleteTemplate(interaction.guildId, name); + if (!deleted) { + await safeEditReply(interaction, { + content: `❌ Template \`${name}\` not found or is a built-in (built-ins cannot be deleted).`, + }); + return; + } + await safeEditReply(interaction, { content: `✅ Template **${name}** deleted.` }); +} + +async function handleShare(interaction) { + const name = interaction.options.getString('name'); + const enabled = interaction.options.getBoolean('enabled'); + const updated = await setTemplateShared(interaction.guildId, name, enabled); + if (!updated) { + await safeEditReply(interaction, { + content: `❌ Template \`${name}\` not found or not owned by this guild.`, + }); + return; + } + await safeEditReply(interaction, { + content: `✅ Template **${name}** is now ${enabled ? '🌐 shared with all guilds' : '🔒 private to this guild'}.`, + }); +} + +// ── Main execute ────────────────────────────────────────────────────────────── + +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const guildConfig = getConfig(interaction.guildId); + if (!hasModeratorPerms(interaction, guildConfig)) { + await safeEditReply(interaction, { + content: '❌ You need moderator or administrator permissions to use this command.', + }); + return; + } + + const sub = interaction.options.getSubcommand(); + + switch (sub) { + case 'list': + await handleList(interaction); + break; + case 'info': + await handleInfo(interaction); + break; + case 'apply': + await handleApply(interaction); + break; + case 'create': + await handleCreate(interaction); + break; + case 'delete': + await handleDelete(interaction); + break; + case 'share': + await handleShare(interaction); + break; + default: + await safeEditReply(interaction, { content: '❓ Unknown subcommand.' }); + } +} diff --git a/src/index.js b/src/index.js index b5e89f32..811267bd 100644 --- a/src/index.js +++ b/src/index.js @@ -44,11 +44,13 @@ import { stopConversationCleanup, } from './modules/ai.js'; import { getConfig, loadConfig } from './modules/config.js'; + import { registerEventHandlers } from './modules/events.js'; import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.js'; +import { seedBuiltinTemplates } from './modules/roleMenuTemplates.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; import { startTriage, stopTriage } from './modules/triage.js'; import { startVoiceFlush, stopVoiceFlush } from './modules/voice.js'; @@ -60,6 +62,7 @@ import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; import { registerCommands } from './utils/registerCommands.js'; import { recordRestart, updateUptimeOnShutdown } from './utils/restartTracker.js'; + import { safeFollowUp, safeReply } from './utils/safeSend.js'; // ES module dirname equivalent @@ -376,6 +379,11 @@ async function startup() { // Record this startup in the restart history table await recordRestart(dbPool, 'startup', BOT_VERSION); + + // Seed built-in role menu templates (idempotent) + await seedBuiltinTemplates().catch((err) => + warn('Failed to seed built-in role menu templates', { error: err.message }), + ); } else { warn('DATABASE_URL not set — using config.json only (no persistence)'); } diff --git a/src/modules/roleMenuTemplates.js b/src/modules/roleMenuTemplates.js new file mode 100644 index 00000000..f9e0cf72 --- /dev/null +++ b/src/modules/roleMenuTemplates.js @@ -0,0 +1,281 @@ +/** + * Role Menu Templates Module + * + * Manages reusable role menu templates — pre-defined (built-in) and + * custom guild-created. Templates can be shared across guilds. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/216 (role menu templates) + */ + +import { getPool } from '../db.js'; +import { info, warn } from '../logger.js'; + +// ── Built-in templates ──────────────────────────────────────────────────────── + +/** + * Pre-defined templates shipped with the bot. + * Options use placeholder label+description; role IDs must be filled in by the + * guild admin when they apply the template (the bot doesn't know their role IDs). + * + * @type {Array<{name: string, description: string, category: string, options: Array<{label: string, description: string}>}>} + */ +export const BUILTIN_TEMPLATES = [ + { + name: 'color-roles', + description: 'Self-assignable colour roles (Red, Blue, Green, Yellow, Purple)', + category: 'colors', + options: [ + { label: '🔴 Red', description: 'Red colour role' }, + { label: '🔵 Blue', description: 'Blue colour role' }, + { label: '🟢 Green', description: 'Green colour role' }, + { label: '🟡 Yellow', description: 'Yellow colour role' }, + { label: '🟣 Purple', description: 'Purple colour role' }, + ], + }, + { + name: 'pronouns', + description: 'Pronoun roles (he/him, she/her, they/them, any)', + category: 'pronouns', + options: [ + { label: 'he/him', description: 'He/Him pronouns' }, + { label: 'she/her', description: 'She/Her pronouns' }, + { label: 'they/them', description: 'They/Them pronouns' }, + { label: 'any pronouns', description: 'Any pronouns' }, + { label: 'ask my pronouns', description: 'Ask me my pronouns' }, + ], + }, + { + name: 'notifications', + description: 'Opt-in notification roles (Announcements, Events, Updates)', + category: 'notifications', + options: [ + { label: '📣 Announcements', description: 'Server announcements' }, + { label: '🎉 Events', description: 'Server event pings' }, + { label: '🔔 Updates', description: 'Bot/server update pings' }, + { label: '📦 Releases', description: 'New release notifications' }, + ], + }, +]; + +// ── Validation ──────────────────────────────────────────────────────────────── + +const MAX_TEMPLATE_NAME_LEN = 64; +const MAX_OPTIONS = 25; +const VALID_NAME_RE = /^[\w\- ]+$/; + +/** + * Validate a template name. + * @param {string} name + * @returns {string|null} Error message, or null if valid. + */ +export function validateTemplateName(name) { + if (typeof name !== 'string' || !name.trim()) return 'Template name is required.'; + if (name.trim().length > MAX_TEMPLATE_NAME_LEN) + return `Template name must be ≤${MAX_TEMPLATE_NAME_LEN} characters.`; + if (!VALID_NAME_RE.test(name.trim())) + return 'Template name may only contain letters, numbers, spaces, hyphens, and underscores.'; + return null; +} + +/** + * Validate template options. + * @param {unknown} options + * @returns {string|null} Error message, or null if valid. + */ +export function validateTemplateOptions(options) { + if (!Array.isArray(options) || options.length === 0) + return 'Template must have at least one option.'; + if (options.length > MAX_OPTIONS) return `Templates support at most ${MAX_OPTIONS} options.`; + for (const [i, opt] of options.entries()) { + if (!opt || typeof opt !== 'object') return `Option ${i + 1} is not a valid object.`; + if (typeof opt.label !== 'string' || !opt.label.trim()) + return `Option ${i + 1} must have a non-empty label.`; + if (opt.label.trim().length > 100) + return `Option ${i + 1} label must be ≤100 characters.`; + // Validate optional description + if (opt.description !== undefined && typeof opt.description !== 'string') + return `Option ${i + 1} description must be a string.`; + // Validate optional roleId + if (opt.roleId !== undefined && typeof opt.roleId !== 'string') + return `Option ${i + 1} roleId must be a string.`; + } + return null; +} + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +/** + * List templates visible to a guild: built-in rows + guild's own + shared. + * + * @param {string} guildId + * @returns {Promise} + */ +export async function listTemplates(guildId) { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT id, name, description, category, created_by_guild_id, + is_builtin, is_shared, options, created_at + FROM role_menu_templates + WHERE is_builtin = TRUE + OR created_by_guild_id = $1 + OR is_shared = TRUE + ORDER BY is_builtin DESC, category, name`, + [guildId], + ); + return rows; +} + +/** + * Get a single template by name that is visible to the guild. + * + * @param {string} guildId + * @param {string} name + * @returns {Promise} + */ +export async function getTemplateByName(guildId, name) { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT id, name, description, category, created_by_guild_id, + is_builtin, is_shared, options, created_at + FROM role_menu_templates + WHERE LOWER(name) = LOWER($1) + AND (is_builtin = TRUE OR created_by_guild_id = $2 OR is_shared = TRUE) + ORDER BY + (created_by_guild_id = $2) DESC, + is_builtin DESC, + name ASC, + id ASC + LIMIT 1`, + [name.trim(), guildId], + ); + return rows[0] ?? null; +} + +/** + * Create a custom template for a guild. + * + * @param {object} params + * @param {string} params.guildId + * @param {string} params.name + * @param {string} [params.description] + * @param {string} [params.category] + * @param {Array<{label: string, description?: string, roleId?: string}>} params.options + * @returns {Promise} The created row. + */ +export async function createTemplate({ + guildId, + name, + description = '', + category = 'custom', + options, +}) { + const pool = getPool(); + const { rows } = await pool.query( + `INSERT INTO role_menu_templates + (name, description, category, created_by_guild_id, is_builtin, is_shared, options) + VALUES ($1, $2, $3, $4, FALSE, FALSE, $5::jsonb) + RETURNING *`, + [name.trim(), description.trim(), category.trim(), guildId, JSON.stringify(options)], + ); + info('Role menu template created', { guildId, name: name.trim() }); + return rows[0]; +} + +/** + * Delete a guild's own custom template. + * + * @param {string} guildId + * @param {string} name + * @returns {Promise} True if a row was deleted. + */ +export async function deleteTemplate(guildId, name) { + const pool = getPool(); + const { rowCount } = await pool.query( + `DELETE FROM role_menu_templates + WHERE LOWER(name) = LOWER($1) + AND created_by_guild_id = $2 + AND is_builtin = FALSE`, + [name.trim(), guildId], + ); + if (rowCount > 0) { + info('Role menu template deleted', { guildId, name: name.trim() }); + } + return rowCount > 0; +} + +/** + * Toggle sharing of a guild's template. + * + * @param {string} guildId + * @param {string} name + * @param {boolean} shared + * @returns {Promise} Updated row, or null if not found. + */ +export async function setTemplateShared(guildId, name, shared) { + const pool = getPool(); + const { rows } = await pool.query( + `UPDATE role_menu_templates + SET is_shared = $1, updated_at = NOW() + WHERE LOWER(name) = LOWER($2) + AND created_by_guild_id = $3 + AND is_builtin = FALSE + RETURNING *`, + [shared, name.trim(), guildId], + ); + if (rows[0]) { + info('Role menu template sharing updated', { guildId, name: name.trim(), shared }); + } else { + warn('setTemplateShared: template not found or not owned by guild', { + guildId, + name: name.trim(), + }); + } + return rows[0] ?? null; +} + +/** + * Seed built-in templates into the database (idempotent — skips existing rows). + * + * @returns {Promise} + */ +export async function seedBuiltinTemplates() { + const pool = getPool(); + for (const tpl of BUILTIN_TEMPLATES) { + await pool.query( + `INSERT INTO role_menu_templates + (name, description, category, created_by_guild_id, is_builtin, is_shared, options) + VALUES ($1, $2, $3, NULL, TRUE, TRUE, $4::jsonb) + ON CONFLICT ON CONSTRAINT idx_rmt_name_guild DO NOTHING`, + [tpl.name, tpl.description, tpl.category, JSON.stringify(tpl.options)], + ); + } + info('Built-in role menu templates seeded', { count: BUILTIN_TEMPLATES.length }); +} + +/** + * Apply a template to a guild's welcome.roleMenu config. + * Returns the merged options array — caller is responsible for saving config. + * + * Built-in templates have no roleId; the guild must map them before options + * are usable in the live role menu. The returned options include whatever + * roleId values are already stored (from a previous apply + edit cycle). + * + * @param {object} template A template row from the DB. + * @param {Array} [existingOptions] Current welcome.roleMenu.options (to preserve role IDs). + * @returns {Array<{label: string, description?: string, roleId: string}>} + */ +export function applyTemplateToOptions(template, existingOptions = []) { + const existingByLabel = Object.fromEntries( + existingOptions.map((opt) => [opt.label?.toLowerCase(), opt.roleId]), + ); + + return template.options.map((opt) => { + const labelKey = opt.label.toLowerCase(); + return { + label: opt.label, + ...(opt.description ? { description: opt.description } : {}), + // Preserve an existing roleId if the label already has one + roleId: existingByLabel[labelKey] || opt.roleId || '', + }; + }); +} diff --git a/tests/commands/rolemenu.test.js b/tests/commands/rolemenu.test.js new file mode 100644 index 00000000..338dbdb0 --- /dev/null +++ b/tests/commands/rolemenu.test.js @@ -0,0 +1,458 @@ +/** + * Tests for /rolemenu command + * @see https://github.com/VolvoxLLC/volvox-bot/issues/135 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ getPool: vi.fn() })); +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +const mockGetConfig = vi.fn().mockReturnValue({}); +const mockSetConfigValue = vi.fn().mockResolvedValue({}); +vi.mock('../../src/modules/config.js', () => ({ + getConfig: (...args) => mockGetConfig(...args), + setConfigValue: (...args) => mockSetConfigValue(...args), +})); + +const mockSafeEditReply = vi.fn().mockResolvedValue({}); +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: (...args) => mockSafeEditReply(...args), +})); + +const mockIsModerator = vi.fn().mockReturnValue(true); +vi.mock('../../src/utils/permissions.js', () => ({ + isModerator: (...args) => mockIsModerator(...args), +})); + +const mockListTemplates = vi.fn(); +const mockGetTemplateByName = vi.fn(); +const mockCreateTemplate = vi.fn(); +const mockDeleteTemplate = vi.fn(); +const mockSetTemplateShared = vi.fn(); +const mockValidateName = vi.fn().mockReturnValue(null); +const mockValidateOptions = vi.fn().mockReturnValue(null); +const mockApplyTemplate = vi.fn().mockReturnValue([{ label: 'Red', roleId: '' }]); + +vi.mock('../../src/modules/roleMenuTemplates.js', () => ({ + listTemplates: (...a) => mockListTemplates(...a), + getTemplateByName: (...a) => mockGetTemplateByName(...a), + createTemplate: (...a) => mockCreateTemplate(...a), + deleteTemplate: (...a) => mockDeleteTemplate(...a), + setTemplateShared: (...a) => mockSetTemplateShared(...a), + validateTemplateName: (...a) => mockValidateName(...a), + validateTemplateOptions: (...a) => mockValidateOptions(...a), + applyTemplateToOptions: (...a) => mockApplyTemplate(...a), + BUILTIN_TEMPLATES: [], +})); + +vi.mock('discord.js', () => { + class MockEmbed { + setTitle() { + return this; + } + setColor() { + return this; + } + setDescription() { + return this; + } + setFooter() { + return this; + } + addFields() { + return this; + } + } + + function chainable() { + const proxy = new Proxy(() => proxy, { + get: () => () => proxy, + apply: () => proxy, + }); + return proxy; + } + + class MockSlashCommandBuilder { + setName() { + return this; + } + setDescription() { + return this; + } + addSubcommandGroup(fn) { + fn(chainable()); + return this; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + EmbedBuilder: MockEmbed, + PermissionFlagsBits: { Administrator: BigInt(8) }, + }; +}); + +// ── Test helpers ─────────────────────────────────────────────────────────────── + +function makeInteraction({ + subcommand = 'list', + name = null, + enabled = null, + merge = null, + optionsJson = null, + description = null, + category = null, + isAdmin = true, +} = {}) { + return { + guildId: 'guild1', + user: { id: 'user1' }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + member: { + permissions: { + has: vi.fn().mockReturnValue(isAdmin), + }, + }, + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + getString: vi.fn((key) => { + if (key === 'name') return name; + if (key === 'options') return optionsJson; + if (key === 'description') return description; + if (key === 'category') return category; + return null; + }), + getBoolean: vi.fn((key) => { + if (key === 'enabled') return enabled; + if (key === 'merge') return merge; + return null; + }), + }, + }; +} + +// ── Import after mocks ──────────────────────────────────────────────────────── + +const { execute, data, adminOnly } = await import('../../src/commands/rolemenu.js'); + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('/rolemenu command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetConfig.mockReturnValue({}); + mockIsModerator.mockReturnValue(true); + }); + + it('exports data and execute', () => { + expect(data).toBeDefined(); + expect(typeof execute).toBe('function'); + expect(adminOnly).toBe(true); + }); + + describe('permission check', () => { + it('rejects non-mod/non-admin users', async () => { + const interaction = makeInteraction({ isAdmin: false }); + mockIsModerator.mockReturnValue(false); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('permissions') }), + ); + }); + }); + + // ── list ──────────────────────────────────────────────────────────────────── + + describe('template list', () => { + it('replies with empty state when no templates', async () => { + mockListTemplates.mockResolvedValueOnce([]); + const interaction = makeInteraction({ subcommand: 'list' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('No templates') }), + ); + }); + + it('replies with embed when templates exist', async () => { + mockListTemplates.mockResolvedValueOnce([ + { + id: 1, + name: 'color-roles', + description: 'Color roles', + category: 'colors', + is_builtin: true, + is_shared: true, + options: [], + }, + ]); + const interaction = makeInteraction({ subcommand: 'list' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + }); + + // ── info ──────────────────────────────────────────────────────────────────── + + describe('template info', () => { + it('replies with not found when template missing', async () => { + mockGetTemplateByName.mockResolvedValueOnce(null); + const interaction = makeInteraction({ subcommand: 'info', name: 'ghost' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('not found') }), + ); + }); + + it('replies with embed when template found', async () => { + mockGetTemplateByName.mockResolvedValueOnce({ + id: 1, + name: 'pronouns', + description: 'Pronoun roles', + category: 'pronouns', + is_builtin: true, + is_shared: true, + options: [{ label: 'they/them' }], + created_at: new Date().toISOString(), + }); + const interaction = makeInteraction({ subcommand: 'info', name: 'pronouns' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + }); + + // ── apply ─────────────────────────────────────────────────────────────────── + + describe('template apply', () => { + it('replies not found when template missing', async () => { + mockGetTemplateByName.mockResolvedValueOnce(null); + const interaction = makeInteraction({ subcommand: 'apply', name: 'ghost' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('not found') }), + ); + }); + + it('calls setConfigValue when template found', async () => { + const tpl = { + name: 'color-roles', + description: 'Color roles', + is_builtin: false, + options: [{ label: 'Red', roleId: '111' }], + }; + mockGetTemplateByName.mockResolvedValueOnce(tpl); + mockApplyTemplate.mockReturnValueOnce([{ label: 'Red', roleId: '111' }]); + + const interaction = makeInteraction({ subcommand: 'apply', name: 'color-roles' }); + await execute(interaction); + + expect(mockSetConfigValue).toHaveBeenCalledWith('welcome.roleMenu.enabled', true, 'guild1'); + expect(mockSetConfigValue).toHaveBeenCalledWith( + 'welcome.roleMenu.options', + expect.any(Array), + 'guild1', + ); + }); + + it('includes warning note for built-in templates', async () => { + const tpl = { + name: 'color-roles', + description: 'Color roles', + is_builtin: true, + options: [{ label: 'Red' }], + }; + mockGetTemplateByName.mockResolvedValueOnce(tpl); + mockApplyTemplate.mockReturnValueOnce([{ label: 'Red', roleId: '' }]); + + const interaction = makeInteraction({ subcommand: 'apply', name: 'color-roles' }); + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('role IDs') }), + ); + }); + }); + + // ── create ────────────────────────────────────────────────────────────────── + + describe('template create', () => { + it('rejects invalid template name', async () => { + mockValidateName.mockReturnValueOnce('Name is invalid.'); + const interaction = makeInteraction({ + subcommand: 'create', + name: '!!bad!!', + optionsJson: '[{"label":"X"}]', + }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Name is invalid') }), + ); + }); + + it('rejects invalid JSON options', async () => { + const interaction = makeInteraction({ + subcommand: 'create', + name: 'my-template', + optionsJson: 'not json', + }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('valid JSON') }), + ); + }); + + it('rejects invalid options array', async () => { + mockValidateOptions.mockReturnValueOnce('At least one option required.'); + const interaction = makeInteraction({ + subcommand: 'create', + name: 'my-template', + optionsJson: '[]', + }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('At least one option') }), + ); + }); + + it('creates template successfully', async () => { + mockCreateTemplate.mockResolvedValueOnce({ name: 'my-template', id: 99 }); + const interaction = makeInteraction({ + subcommand: 'create', + name: 'my-template', + optionsJson: '[{"label":"Red","roleId":"111"}]', + }); + + await execute(interaction); + + expect(mockCreateTemplate).toHaveBeenCalled(); + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('created') }), + ); + }); + + it('handles duplicate name error (23505)', async () => { + const err = Object.assign(new Error('unique violation'), { code: '23505' }); + mockCreateTemplate.mockRejectedValueOnce(err); + const interaction = makeInteraction({ + subcommand: 'create', + name: 'existing', + optionsJson: '[{"label":"X"}]', + }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('already exists') }), + ); + }); + }); + + // ── delete ────────────────────────────────────────────────────────────────── + + describe('template delete', () => { + it('replies not found when delete returns false', async () => { + mockDeleteTemplate.mockResolvedValueOnce(false); + const interaction = makeInteraction({ subcommand: 'delete', name: 'ghost' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('not found') }), + ); + }); + + it('replies success when delete returns true', async () => { + mockDeleteTemplate.mockResolvedValueOnce(true); + const interaction = makeInteraction({ subcommand: 'delete', name: 'my-tpl' }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('deleted') }), + ); + }); + }); + + // ── share ──────────────────────────────────────────────────────────────────── + + describe('template share', () => { + it('replies not found when template not owned by guild', async () => { + mockSetTemplateShared.mockResolvedValueOnce(null); + const interaction = makeInteraction({ subcommand: 'share', name: 'ghost', enabled: true }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('not found') }), + ); + }); + + it('replies shared when sharing enabled', async () => { + mockSetTemplateShared.mockResolvedValueOnce({ name: 'my-tpl', is_shared: true }); + const interaction = makeInteraction({ subcommand: 'share', name: 'my-tpl', enabled: true }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('shared with all guilds') }), + ); + }); + + it('replies private when sharing disabled', async () => { + mockSetTemplateShared.mockResolvedValueOnce({ name: 'my-tpl', is_shared: false }); + const interaction = makeInteraction({ subcommand: 'share', name: 'my-tpl', enabled: false }); + + await execute(interaction); + + expect(mockSafeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('private') }), + ); + }); + }); +}); diff --git a/tests/modules/roleMenuTemplates.test.js b/tests/modules/roleMenuTemplates.test.js new file mode 100644 index 00000000..f6ea5cdf --- /dev/null +++ b/tests/modules/roleMenuTemplates.test.js @@ -0,0 +1,380 @@ +/** + * Tests for src/modules/roleMenuTemplates.js + * + * Covers: BUILTIN_TEMPLATES, validateTemplateName, validateTemplateOptions, + * listTemplates, getTemplateByName, createTemplate, deleteTemplate, + * setTemplateShared, seedBuiltinTemplates, applyTemplateToOptions. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockQuery = vi.fn(); + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(() => ({ query: mockQuery })), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +import { + applyTemplateToOptions, + BUILTIN_TEMPLATES, + createTemplate, + deleteTemplate, + getTemplateByName, + listTemplates, + seedBuiltinTemplates, + setTemplateShared, + validateTemplateName, + validateTemplateOptions, +} from '../../src/modules/roleMenuTemplates.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRow(overrides = {}) { + return { + id: 1, + name: 'test-template', + description: 'A test template', + category: 'custom', + created_by_guild_id: 'guild1', + is_builtin: false, + is_shared: false, + options: [{ label: 'Red', description: 'Red role', roleId: '111' }], + created_at: new Date().toISOString(), + ...overrides, + }; +} + +// ── BUILTIN_TEMPLATES ───────────────────────────────────────────────────────── + +describe('BUILTIN_TEMPLATES', () => { + it('should export an array of templates', () => { + expect(Array.isArray(BUILTIN_TEMPLATES)).toBe(true); + expect(BUILTIN_TEMPLATES.length).toBeGreaterThan(0); + }); + + it('should include color-roles, pronouns, and notifications', () => { + const names = BUILTIN_TEMPLATES.map((t) => t.name); + expect(names).toContain('color-roles'); + expect(names).toContain('pronouns'); + expect(names).toContain('notifications'); + }); + + it('each template should have name, description, category, and non-empty options', () => { + for (const tpl of BUILTIN_TEMPLATES) { + expect(typeof tpl.name).toBe('string'); + expect(typeof tpl.description).toBe('string'); + expect(typeof tpl.category).toBe('string'); + expect(Array.isArray(tpl.options)).toBe(true); + expect(tpl.options.length).toBeGreaterThan(0); + for (const opt of tpl.options) { + expect(typeof opt.label).toBe('string'); + expect(opt.label.length).toBeGreaterThan(0); + } + } + }); +}); + +// ── validateTemplateName ────────────────────────────────────────────────────── + +describe('validateTemplateName', () => { + it('returns null for valid names', () => { + expect(validateTemplateName('color-roles')).toBeNull(); + expect(validateTemplateName('My Template')).toBeNull(); + expect(validateTemplateName('template_1')).toBeNull(); + }); + + it('returns error for empty name', () => { + expect(validateTemplateName('')).toBeTruthy(); + expect(validateTemplateName(' ')).toBeTruthy(); + }); + + it('returns error for non-string', () => { + expect(validateTemplateName(null)).toBeTruthy(); + expect(validateTemplateName(123)).toBeTruthy(); + }); + + it('returns error for name exceeding 64 chars', () => { + expect(validateTemplateName('a'.repeat(65))).toBeTruthy(); + }); + + it('returns error for invalid characters', () => { + expect(validateTemplateName('bad@name!')).toBeTruthy(); + }); + + it('accepts exactly 64 chars', () => { + expect(validateTemplateName('a'.repeat(64))).toBeNull(); + }); +}); + +// ── validateTemplateOptions ─────────────────────────────────────────────────── + +describe('validateTemplateOptions', () => { + it('returns null for valid options', () => { + expect(validateTemplateOptions([{ label: 'Red' }])).toBeNull(); + expect(validateTemplateOptions([{ label: 'Red', roleId: '123' }])).toBeNull(); + }); + + it('returns error for empty array', () => { + expect(validateTemplateOptions([])).toBeTruthy(); + }); + + it('returns error for non-array', () => { + expect(validateTemplateOptions(null)).toBeTruthy(); + expect(validateTemplateOptions('bad')).toBeTruthy(); + }); + + it('returns error for more than 25 options', () => { + const opts = Array.from({ length: 26 }, (_, i) => ({ label: `Role ${i}` })); + expect(validateTemplateOptions(opts)).toBeTruthy(); + }); + + it('returns error for option without label', () => { + expect(validateTemplateOptions([{ description: 'no label' }])).toBeTruthy(); + }); + + it('returns error for label > 100 chars', () => { + expect(validateTemplateOptions([{ label: 'a'.repeat(101) }])).toBeTruthy(); + }); + + it('accepts exactly 25 options', () => { + const opts = Array.from({ length: 25 }, (_, i) => ({ label: `Role ${i}` })); + expect(validateTemplateOptions(opts)).toBeNull(); + }); +}); + +// ── listTemplates ───────────────────────────────────────────────────────────── + +describe('listTemplates', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns rows from the database', async () => { + const rows = [makeRow(), makeRow({ name: 'pronouns', is_builtin: true })]; + mockQuery.mockResolvedValueOnce({ rows }); + + const result = await listTemplates('guild1'); + expect(result).toEqual(rows); + expect(mockQuery).toHaveBeenCalledOnce(); + expect(mockQuery.mock.calls[0][0]).toMatch(/SELECT/i); + }); + + it('passes guildId as parameter', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + await listTemplates('myguild'); + expect(mockQuery.mock.calls[0][1]).toContain('myguild'); + }); +}); + +// ── getTemplateByName ───────────────────────────────────────────────────────── + +describe('getTemplateByName', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns first matching row', async () => { + const row = makeRow(); + mockQuery.mockResolvedValueOnce({ rows: [row] }); + + const result = await getTemplateByName('guild1', 'test-template'); + expect(result).toEqual(row); + }); + + it('returns null when no rows found', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + const result = await getTemplateByName('guild1', 'nonexistent'); + expect(result).toBeNull(); + }); +}); + +// ── createTemplate ──────────────────────────────────────────────────────────── + +describe('createTemplate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('inserts template and returns created row', async () => { + const row = makeRow(); + mockQuery.mockResolvedValueOnce({ rows: [row] }); + + const result = await createTemplate({ + guildId: 'guild1', + name: 'test-template', + description: 'A test template', + category: 'custom', + options: [{ label: 'Red', roleId: '111' }], + }); + + expect(result).toEqual(row); + expect(mockQuery).toHaveBeenCalledOnce(); + expect(mockQuery.mock.calls[0][0]).toMatch(/INSERT/i); + }); + + it('trims name before insert', async () => { + const row = makeRow(); + mockQuery.mockResolvedValueOnce({ rows: [row] }); + + await createTemplate({ + guildId: 'guild1', + name: ' padded ', + options: [{ label: 'X' }], + }); + + expect(mockQuery.mock.calls[0][1][0]).toBe('padded'); + }); + + it('serialises options as JSON', async () => { + const row = makeRow(); + mockQuery.mockResolvedValueOnce({ rows: [row] }); + + const opts = [{ label: 'Red', roleId: '111' }]; + await createTemplate({ guildId: 'guild1', name: 'test', options: opts }); + + expect(mockQuery.mock.calls[0][1][4]).toBe(JSON.stringify(opts)); + }); +}); + +// ── deleteTemplate ──────────────────────────────────────────────────────────── + +describe('deleteTemplate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns true when a row is deleted', async () => { + mockQuery.mockResolvedValueOnce({ rowCount: 1 }); + const result = await deleteTemplate('guild1', 'test-template'); + expect(result).toBe(true); + }); + + it('returns false when no rows deleted', async () => { + mockQuery.mockResolvedValueOnce({ rowCount: 0 }); + const result = await deleteTemplate('guild1', 'nonexistent'); + expect(result).toBe(false); + }); + + it('passes guildId as restriction parameter', async () => { + mockQuery.mockResolvedValueOnce({ rowCount: 0 }); + await deleteTemplate('myguild', 'test'); + expect(mockQuery.mock.calls[0][1]).toContain('myguild'); + }); +}); + +// ── setTemplateShared ───────────────────────────────────────────────────────── + +describe('setTemplateShared', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns updated row when found', async () => { + const row = makeRow({ is_shared: true }); + mockQuery.mockResolvedValueOnce({ rows: [row] }); + + const result = await setTemplateShared('guild1', 'test-template', true); + expect(result).toEqual(row); + }); + + it('returns null when template not found/not owned', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + const result = await setTemplateShared('guild1', 'not-mine', true); + expect(result).toBeNull(); + }); +}); + +// ── seedBuiltinTemplates ────────────────────────────────────────────────────── + +describe('seedBuiltinTemplates', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls INSERT for each built-in template', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + + await seedBuiltinTemplates(); + + expect(mockQuery).toHaveBeenCalledTimes(BUILTIN_TEMPLATES.length); + for (const call of mockQuery.mock.calls) { + expect(call[0]).toMatch(/INSERT/i); + expect(call[0]).toMatch(/ON CONFLICT/i); + } + }); + + it('passes is_builtin=true for all built-in templates', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + await seedBuiltinTemplates(); + // Each call should mark is_builtin via SQL — verify options serialise correctly + for (const [i, tpl] of BUILTIN_TEMPLATES.entries()) { + expect(mockQuery.mock.calls[i][1][0]).toBe(tpl.name); + } + }); +}); + +// ── applyTemplateToOptions ──────────────────────────────────────────────────── + +describe('applyTemplateToOptions', () => { + const template = { + options: [ + { label: 'Red', description: 'Red role' }, + { label: 'Blue', description: 'Blue role' }, + ], + }; + + it('maps template options with empty roleId when no existing options', () => { + const result = applyTemplateToOptions(template, []); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ label: 'Red', description: 'Red role', roleId: '' }); + expect(result[1]).toMatchObject({ label: 'Blue', description: 'Blue role', roleId: '' }); + }); + + it('preserves existing roleIds by matching label (case-insensitive)', () => { + const existing = [ + { label: 'Red', roleId: 'role-red-123' }, + { label: 'BLUE', roleId: 'role-blue-456' }, + ]; + const result = applyTemplateToOptions(template, existing); + expect(result[0].roleId).toBe('role-red-123'); + expect(result[1].roleId).toBe('role-blue-456'); + }); + + it('uses roleId from template options if set', () => { + const tplWithIds = { + options: [{ label: 'Red', roleId: 'tpl-red-999' }], + }; + const result = applyTemplateToOptions(tplWithIds, []); + expect(result[0].roleId).toBe('tpl-red-999'); + }); + + it('prefers existing roleId over template roleId', () => { + const tplWithIds = { + options: [{ label: 'Red', roleId: 'tpl-red-111' }], + }; + const existing = [{ label: 'Red', roleId: 'existing-red-222' }]; + const result = applyTemplateToOptions(tplWithIds, existing); + // existing roleId takes precedence (opt.roleId || existingByLabel) + // template opt has roleId 'tpl-red-111' which is truthy — it wins + expect(result[0].roleId).toBe('existing-red-222'); + }); + + it('does not include description if not present in template option', () => { + const tplNoDesc = { options: [{ label: 'X' }] }; + const result = applyTemplateToOptions(tplNoDesc, []); + expect(result[0]).not.toHaveProperty('description'); + }); + + it('defaults existingOptions to empty array', () => { + const result = applyTemplateToOptions(template); + expect(result).toHaveLength(2); + }); +});