UNPKG

meocord

Version:

Decorator-based Discord bot framework built on discord.js. Brings NestJS-style controllers, dependency injection, guards, and testing utilities to bot development — with a full CLI and TypeScript-first design.

451 lines (448 loc) 20.5 kB
import 'reflect-metadata'; import { jest } from '@jest/globals'; import { InteractionType, ComponentType, ApplicationCommandType, CommandInteractionOptionResolver, GuildMessageManager, ThreadManager, DMMessageManager, MessageManager, ThreadMemberManager, Client, ApplicationCommandManager, UserManager, ChannelManager, GuildManager, ClientUser, Guild, GuildMemberManager, GuildChannelManager, RoleManager, GuildBanManager, Message, User, GuildMember, TextChannel, ThreadChannel, MessageMentions } from 'discord.js'; // --------------------------------------------------------------------------- // stubDeep — Proxy that auto-creates jest.fn() on any property access // --------------------------------------------------------------------------- const SKIP = new Set([ 'constructor', 'toString', 'valueOf', 'toJSON', 'then' ]); function stubDeep(instance, externalStubs) { const stubs = externalStubs ?? new Map(); return new Proxy(instance, { get (target, prop) { // Always pass through symbols if (typeof prop === 'symbol') { return Reflect.get(target, prop, target); } const key = prop; // 'then' must be undefined — prevents jest treating the mock as a Promise if (key === 'then') return undefined; // Own property writes take precedence (e.g. interaction.guildId = 'abc') if (Object.prototype.hasOwnProperty.call(target, key)) { return Reflect.get(target, prop, target); } // Skip passthrough props — return prototype value as-is if (SKIP.has(key)) { return Reflect.get(target, prop, target); } // Return cached stub if (stubs.has(key)) return stubs.get(key); // Walk the prototype chain to check if it's a function let proto = Object.getPrototypeOf(target); let protoValue; while(proto !== null){ const desc = Object.getOwnPropertyDescriptor(proto, key); if (desc !== undefined) { protoValue = desc.value; break; } proto = Object.getPrototypeOf(proto); } const stub = typeof protoValue === 'function' ? jest.fn() : stubDeep({}); stubs.set(key, stub); return stub; }, set (target, prop, value) { Reflect.set(target, prop, value, target); return true; } }); } // --------------------------------------------------------------------------- // Class type fields — sets this.type / commandType / componentType on the // instance so all prototype type-guard methods run with real logic // --------------------------------------------------------------------------- const CLASS_TYPE_FIELDS = { ChatInputCommandInteraction: { type: InteractionType.ApplicationCommand, commandType: ApplicationCommandType.ChatInput }, ContextMenuCommandInteraction: { type: InteractionType.ApplicationCommand, commandType: ApplicationCommandType.User }, UserContextMenuCommandInteraction: { type: InteractionType.ApplicationCommand, commandType: ApplicationCommandType.User }, MessageContextMenuCommandInteraction: { type: InteractionType.ApplicationCommand, commandType: ApplicationCommandType.Message }, PrimaryEntryPointCommandInteraction: { type: InteractionType.ApplicationCommand, commandType: ApplicationCommandType.PrimaryEntryPoint }, MessageComponentInteraction: { type: InteractionType.MessageComponent }, ButtonInteraction: { type: InteractionType.MessageComponent, componentType: ComponentType.Button }, StringSelectMenuInteraction: { type: InteractionType.MessageComponent, componentType: ComponentType.StringSelect }, UserSelectMenuInteraction: { type: InteractionType.MessageComponent, componentType: ComponentType.UserSelect }, RoleSelectMenuInteraction: { type: InteractionType.MessageComponent, componentType: ComponentType.RoleSelect }, MentionableSelectMenuInteraction: { type: InteractionType.MessageComponent, componentType: ComponentType.MentionableSelect }, ChannelSelectMenuInteraction: { type: InteractionType.MessageComponent, componentType: ComponentType.ChannelSelect }, ModalSubmitInteraction: { type: InteractionType.ModalSubmit }, AutocompleteInteraction: { type: InteractionType.ApplicationCommandAutocomplete } }; // All known pure type-guard methods on BaseInteraction and its subclasses. // These are wired as jest.fn() wrapping the real prototype logic so they return // correct values by default and can still be overridden per test. const TYPE_GUARD_METHODS = [ 'isCommand', 'isChatInputCommand', 'isContextMenuCommand', 'isUserContextMenuCommand', 'isMessageContextMenuCommand', 'isPrimaryEntryPointCommand', 'isMessageComponent', 'isButton', 'isStringSelectMenu', 'isUserSelectMenu', 'isRoleSelectMenu', 'isMentionableSelectMenu', 'isChannelSelectMenu', 'isAnySelectMenu', 'isSelectMenu', 'isModalSubmit', 'isAutocomplete', 'isRepliable' ]; function findPrototypeMethod(instance, name) { let proto = Object.getPrototypeOf(instance); while(proto !== null){ const desc = Object.getOwnPropertyDescriptor(proto, name); if (desc?.value && typeof desc.value === 'function') return desc.value; proto = Object.getPrototypeOf(proto); } return null; } // --------------------------------------------------------------------------- // createMockInteraction // --------------------------------------------------------------------------- /** * Creates a smart mock instance of any discord.js class. The prototype chain * is preserved so `instanceof` checks at every level pass. * * **Type guards** (`isButton()`, `isRepliable()`, etc.) run the real discord.js * prototype logic — no manual `.mockReturnValue(true)` setup needed. They are * still `jest.fn()` so you can override them per test. * * **Reply state machine** — `replied` and `deferred` start as `false`. Calling * `reply()` or `deferReply()` twice throws, just like a real interaction would. * `followUp()`, `editReply()`, and `deleteReply()` throw if called before any * reply. These are still `jest.fn()` so call assertions work normally. * * All other methods are auto-stubbed as `jest.fn()` via Proxy. * * @example * ```ts * const interaction = createMockInteraction(ButtonInteraction) * interaction.guildId = 'guild-123' * interaction.isButton() // → true (real logic, no setup) * interaction.isRepliable() // → true (real logic, no setup) * interaction.replied // → false (real state) * await interaction.reply({ content: 'hi' }) * interaction.replied // → true * await interaction.reply({}) // throws — already replied * ``` */ function createMockInteraction(Class) { const instance = Object.create(Class.prototype); const stubs = new Map(); // Set type fields so all prototype type-guard methods compute the right value const fields = CLASS_TYPE_FIELDS[Class.name]; if (fields !== undefined) { for (const [key, value] of Object.entries(fields)){ instance[key] = value; } } // Wire each type guard as jest.fn() calling the real prototype implementation. // Correct by default; overridable per test via .mockReturnValue(). for (const name of TYPE_GUARD_METHODS){ const method = findPrototypeMethod(instance, name); if (method !== null) { stubs.set(name, jest.fn().mockImplementation(()=>method.call(instance))); } } // Set up reply state machine for repliable interactions const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable'); const repliable = isRepliableMethod !== null && isRepliableMethod.call(instance); if (repliable) { instance.replied = false; instance.deferred = false; instance.ephemeral = false; const alreadyReplied = ()=>new Error('The reply to this interaction has already been sent or deferred.'); const notYetReplied = (method)=>new Error(`Cannot call ${method}() before replying or deferring.`); const hasEphemeralFlag = (options)=>{ if (!options) return false; if (options.ephemeral === true) return true; const { flags } = options; if (typeof flags === 'number') return (flags & 64) !== 0; if (typeof flags === 'bigint') return (flags & 64n) !== 0n; return false; }; stubs.set('reply', jest.fn(async (...args)=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.replied = true; if (hasEphemeralFlag(args[0])) instance.ephemeral = true; })); stubs.set('deferReply', jest.fn(async (...args)=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.deferred = true; if (hasEphemeralFlag(args[0])) instance.ephemeral = true; })); stubs.set('followUp', jest.fn(async ()=>{ if (!instance.deferred && !instance.replied) throw notYetReplied('followUp'); instance.replied = true; return createMockMessage(); })); stubs.set('editReply', jest.fn(async ()=>{ if (!instance.deferred && !instance.replied) throw notYetReplied('editReply'); instance.replied = true; return createMockMessage(); })); stubs.set('deleteReply', jest.fn(async ()=>{ if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply'); })); // deferUpdate / update — MessageComponentInteraction only (type === MessageComponent) if (instance.type === InteractionType.MessageComponent) { stubs.set('update', jest.fn(async ()=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.replied = true; })); stubs.set('deferUpdate', jest.fn(async ()=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.deferred = true; })); } } return stubDeep(instance, stubs); } // --------------------------------------------------------------------------- // Convenience wrappers for common discord.js classes // --------------------------------------------------------------------------- /** Creates a mock {@link User}. All methods are auto-stubbed as `jest.fn()`. */ const createMockUser = ()=>createMockInteraction(User); /** * Creates a mock {@link Client}. * * Manager methods that are constructor-assigned (not on the prototype) are * pre-initialized as `jest.fn()` so they work out of the box without manual * setup: `users.fetch`, `channels.fetch`, `guilds.fetch`, and * `application.commands.fetch`. */ function createMockClient() { const instance = Object.create(Client.prototype); // Manager properties are constructor-assigned — pre-initialize as prototype-based // stubs so ALL manager methods (not just fetch) are auto-stubbed as jest.fn(). const appInstance = Object.create(null); appInstance.commands = stubDeep(Object.create(ApplicationCommandManager.prototype)); instance.users = stubDeep(Object.create(UserManager.prototype)); instance.channels = stubDeep(Object.create(ChannelManager.prototype)); instance.guilds = stubDeep(Object.create(GuildManager.prototype)); instance.user = stubDeep(Object.create(ClientUser.prototype)); instance.application = stubDeep(appInstance); return stubDeep(instance); } /** * Creates a mock {@link Guild}. * * Manager properties are constructor-assigned in discord.js. This factory * pre-initializes each as a prototype-based stub so all methods are * auto-stubbed as `jest.fn()`. * * Pre-initialized: `members`, `channels`, `roles`, `bans`. */ function createMockGuild() { const instance = Object.create(Guild.prototype); instance.members = stubDeep(Object.create(GuildMemberManager.prototype)); instance.channels = stubDeep(Object.create(GuildChannelManager.prototype)); instance.roles = stubDeep(Object.create(RoleManager.prototype)); instance.bans = stubDeep(Object.create(GuildBanManager.prototype)); return stubDeep(instance); } /** * Creates a mock channel of the given class (e.g. `TextChannel`, `DMChannel`). * * Manager properties that are constructor-assigned in discord.js are * pre-initialized as prototype-based stubs so all methods are auto-stubbed * as `jest.fn()`: * - Guild text channels (`TextChannel`, `NewsChannel`): `messages`, `threads` * - `DMChannel`: `messages` * - `ThreadChannel`: `messages`, `members` */ function createMockChannel(Class) { const instance = Object.create(Class.prototype); // Guild text channels (TextChannel, NewsChannel) — messages & threads // assigned in BaseGuildTextChannel constructor if ('messages' in Class.prototype || Class.name === 'TextChannel' || Class.name === 'NewsChannel') { instance.messages = stubDeep(Object.create(GuildMessageManager.prototype)); instance.threads = stubDeep(Object.create(ThreadManager.prototype)); } // DMChannel — messages assigned in DMChannel constructor if (Class.name === 'DMChannel') { instance.messages = stubDeep(Object.create(DMMessageManager.prototype)); } // ThreadChannel — messages & members assigned in ThreadChannel constructor if (Class.name === 'ThreadChannel') { instance.messages = stubDeep(Object.create(MessageManager.prototype)); instance.members = stubDeep(Object.create(ThreadMemberManager.prototype)); } return stubDeep(instance); } /** * Creates a smart mock {@link Message}. * * Tracks a `deleted` boolean. `delete()`, `edit()`, `reply()`, `react()`, * `pin()`, and `unpin()` throw if the message has already been deleted. * `edit()` and `reply()` resolve to a new mock `Message` instance. * * Constructor-assigned and getter properties are pre-initialized as * prototype-based stubs so `msg.author.send`, `msg.member.fetch`, * `msg.channel.send`, `msg.guild.members.fetch`, `msg.thread.fetch`, * and `msg.mentions.has` all work out of the box. * * All methods remain `jest.fn()` — overridable per test. * * Note: `createMockInteraction`'s `followUp()` and `editReply()` stubs return * a `createMockMessage()` by default, matching the official return types. */ /** * Internal guild stub for use inside createMockMessage. * Reuses the same manager stubs as createMockGuild but avoids * a circular reference in the module. */ function createMockGuildForMessage() { const guild = Object.create(Guild.prototype); guild.members = stubDeep(Object.create(GuildMemberManager.prototype)); guild.channels = stubDeep(Object.create(GuildChannelManager.prototype)); guild.roles = stubDeep(Object.create(RoleManager.prototype)); guild.bans = stubDeep(Object.create(GuildBanManager.prototype)); return stubDeep(guild); } function createMockMessage() { const instance = Object.create(Message.prototype); const stubs = new Map(); instance.deleted = false; // Constructor-assigned — set as prototype-based stubs instance.author = stubDeep(Object.create(User.prototype)); // Getters on the prototype — the proxy sees them as functions and returns // jest.fn(), which is wrong. Pre-initialize as own properties to shadow // the prototype getters. Object.defineProperty(instance, 'member', { value: stubDeep(Object.create(GuildMember.prototype)), writable: true }); Object.defineProperty(instance, 'channel', { value: stubDeep(Object.create(TextChannel.prototype)), writable: true }); Object.defineProperty(instance, 'guild', { value: createMockGuildForMessage(), writable: true }); Object.defineProperty(instance, 'thread', { value: stubDeep(Object.create(ThreadChannel.prototype)), writable: true }); // MessageMentions — constructor-assigned, has methods like .has(), .members instance.mentions = stubDeep(Object.create(MessageMentions.prototype)); const alreadyDeleted = ()=>new Error('This message has already been deleted.'); stubs.set('delete', jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); instance.deleted = true; })); stubs.set('edit', jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); return createMockMessage(); })); stubs.set('reply', jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); return createMockMessage(); })); stubs.set('react', jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); })); stubs.set('pin', jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); })); stubs.set('unpin', jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); })); return stubDeep(instance, stubs); } /** * Builds a typed options resolver from a plain record. Mirrors how the real * `CommandInteractionOptionResolver` works: declare what options the command * was invoked with, and the resolver finds them by name. * * All explicit methods are `jest.fn()` — override per test with `.mockReturnValue()`. * Methods not listed (e.g. `getAttachment`) are auto-stubbed by the Proxy. * * @example * ```ts * const interaction = createMockInteraction(ChatInputCommandInteraction) * interaction.options = createChatInputOptions({ * subcommandGroup: 'daily', * subcommand: 'notes', * uid: 12345678, * }) * interaction.options.getSubcommand() // → 'notes' * interaction.options.getNumber('uid') // → 12345678 * ``` */ function createChatInputOptions(opts = {}) { const { subcommandGroup = null, subcommand = null, ...values } = opts; function resolveOrThrow(name, value, required) { if (value === null) { if (required === true) throw new Error(`Option "${name}" is required but was not provided.`); return null; } return value; } function resolveSubEntry(field, label, required) { if (field === null) { if (required === true) throw new Error(`No ${label} found.`); return null; } return field; } const isObjectOption = (v)=>typeof v === 'object' && v !== null && 'id' in v; // Use a real prototype instance so unlisted methods (e.g. getAttachment) // are found on the prototype chain and auto-stubbed as jest.fn() const base = Object.create(CommandInteractionOptionResolver.prototype); base.getSubcommandGroup = jest.fn((required)=>resolveSubEntry(subcommandGroup, 'subcommand group', required)); base.getSubcommand = jest.fn((required)=>resolveSubEntry(subcommand, 'subcommand', required)); base.getString = jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'string' ? values[name] : null, required)); base.getNumber = jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'number' ? values[name] : null, required)); base.getInteger = jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'number' ? values[name] : null, required)); base.getBoolean = jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'boolean' ? values[name] : null, required)); const getObjectOption = (name, required)=>resolveOrThrow(name, isObjectOption(values[name]) ? values[name] : null, required); base.getUser = jest.fn(getObjectOption); base.getRole = jest.fn(getObjectOption); base.getChannel = jest.fn(getObjectOption); base.getMember = jest.fn(getObjectOption); base.getMentionable = jest.fn(getObjectOption); return stubDeep(base); } export { createChatInputOptions, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMessage, createMockUser };