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.

571 lines (566 loc) 24.9 kB
'use strict'; require('reflect-metadata'); var inversify = require('inversify'); var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs'); var globals = require('@jest/globals'); var discord_js = require('discord.js'); function isValueProvider(p) { return 'useValue' in p; } /** * Resolved test module. Retrieve instances via `.get()`. */ class TestingModule { get(token) { return this.container.get(token); } constructor(container){ this.container = container; } } /** * Builder returned by `MeoCordTestingModule.create()`. * Call `.compile()` to get the resolved `TestingModule`. */ class TestingModuleBuilder { overrideProvider(token) { return { useValue: (value)=>{ this.overrides.set(token, { provide: token, useValue: value }); return this; } }; } overrideGuard(guard) { return { useValue: (stub)=>{ this.guardOverrides.set(guard, stub); return this; } }; } compile() { const container = new inversify.Container(); // Merge explicit providers with overrides (overrides win) const providers = new Map(); for (const p of this.options.providers ?? []){ providers.set(p.provide, p); } for (const [token, override] of this.overrides){ providers.set(token, override); } // Bind explicit providers for (const provider of providers.values()){ if (isValueProvider(provider)) { container.bind(provider.provide).toConstantValue(provider.useValue); } else { const cls = provider.useClass; if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, cls)) { inversify.injectable()(cls); } container.bind(provider.provide).to(cls).inSingletonScope(); } } // Bind guard overrides — prevents inversify from auto-wiring guard dependencies for (const [guardClass, stub] of this.guardOverrides){ container.bind(guardClass).toConstantValue(stub); } // Recursively bind controllers and their dependencies, skipping already-bound tokens const bindClass = (cls)=>{ if (container.isBound(cls)) return; if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, cls)) { inversify.injectable()(cls); } container.bind(cls).toSelf().inSingletonScope(); const deps = Reflect.getMetadata(metadataKey_enum.MetadataKey.ParamTypes, cls) || []; for (const dep of deps){ bindClass(dep); } }; for (const ctrl of this.options.controllers ?? []){ bindClass(ctrl); // Stamp container on controller class so @UseGuard works in tests too Reflect.defineMetadata(metadataKey_enum.MetadataKey.Container, container, ctrl); } return new TestingModule(container); } constructor(options){ this.options = options; this.overrides = new Map(); this.guardOverrides = new Map(); } } /** * Entry point for building isolated test modules. * * @example * ```typescript * const module = MeoCordTestingModule.create({ * controllers: [PingController], * providers: [ * { provide: PingService, useValue: { handlePing: jest.fn().mockResolvedValue('pong') } }, * ], * }).compile() * * const controller = module.get(PingController) * ``` */ class MeoCordTestingModule { static create(options) { return new TestingModuleBuilder(options); } } // --------------------------------------------------------------------------- // 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' ? globals.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: discord_js.InteractionType.ApplicationCommand, commandType: discord_js.ApplicationCommandType.ChatInput }, ContextMenuCommandInteraction: { type: discord_js.InteractionType.ApplicationCommand, commandType: discord_js.ApplicationCommandType.User }, UserContextMenuCommandInteraction: { type: discord_js.InteractionType.ApplicationCommand, commandType: discord_js.ApplicationCommandType.User }, MessageContextMenuCommandInteraction: { type: discord_js.InteractionType.ApplicationCommand, commandType: discord_js.ApplicationCommandType.Message }, PrimaryEntryPointCommandInteraction: { type: discord_js.InteractionType.ApplicationCommand, commandType: discord_js.ApplicationCommandType.PrimaryEntryPoint }, MessageComponentInteraction: { type: discord_js.InteractionType.MessageComponent }, ButtonInteraction: { type: discord_js.InteractionType.MessageComponent, componentType: discord_js.ComponentType.Button }, StringSelectMenuInteraction: { type: discord_js.InteractionType.MessageComponent, componentType: discord_js.ComponentType.StringSelect }, UserSelectMenuInteraction: { type: discord_js.InteractionType.MessageComponent, componentType: discord_js.ComponentType.UserSelect }, RoleSelectMenuInteraction: { type: discord_js.InteractionType.MessageComponent, componentType: discord_js.ComponentType.RoleSelect }, MentionableSelectMenuInteraction: { type: discord_js.InteractionType.MessageComponent, componentType: discord_js.ComponentType.MentionableSelect }, ChannelSelectMenuInteraction: { type: discord_js.InteractionType.MessageComponent, componentType: discord_js.ComponentType.ChannelSelect }, ModalSubmitInteraction: { type: discord_js.InteractionType.ModalSubmit }, AutocompleteInteraction: { type: discord_js.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, globals.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', globals.jest.fn(async (...args)=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.replied = true; if (hasEphemeralFlag(args[0])) instance.ephemeral = true; })); stubs.set('deferReply', globals.jest.fn(async (...args)=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.deferred = true; if (hasEphemeralFlag(args[0])) instance.ephemeral = true; })); stubs.set('followUp', globals.jest.fn(async ()=>{ if (!instance.deferred && !instance.replied) throw notYetReplied('followUp'); instance.replied = true; return createMockMessage(); })); stubs.set('editReply', globals.jest.fn(async ()=>{ if (!instance.deferred && !instance.replied) throw notYetReplied('editReply'); instance.replied = true; return createMockMessage(); })); stubs.set('deleteReply', globals.jest.fn(async ()=>{ if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply'); })); // deferUpdate / update — MessageComponentInteraction only (type === MessageComponent) if (instance.type === discord_js.InteractionType.MessageComponent) { stubs.set('update', globals.jest.fn(async ()=>{ if (instance.deferred || instance.replied) throw alreadyReplied(); instance.replied = true; })); stubs.set('deferUpdate', globals.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(discord_js.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(discord_js.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(discord_js.ApplicationCommandManager.prototype)); instance.users = stubDeep(Object.create(discord_js.UserManager.prototype)); instance.channels = stubDeep(Object.create(discord_js.ChannelManager.prototype)); instance.guilds = stubDeep(Object.create(discord_js.GuildManager.prototype)); instance.user = stubDeep(Object.create(discord_js.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(discord_js.Guild.prototype); instance.members = stubDeep(Object.create(discord_js.GuildMemberManager.prototype)); instance.channels = stubDeep(Object.create(discord_js.GuildChannelManager.prototype)); instance.roles = stubDeep(Object.create(discord_js.RoleManager.prototype)); instance.bans = stubDeep(Object.create(discord_js.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(discord_js.GuildMessageManager.prototype)); instance.threads = stubDeep(Object.create(discord_js.ThreadManager.prototype)); } // DMChannel — messages assigned in DMChannel constructor if (Class.name === 'DMChannel') { instance.messages = stubDeep(Object.create(discord_js.DMMessageManager.prototype)); } // ThreadChannel — messages & members assigned in ThreadChannel constructor if (Class.name === 'ThreadChannel') { instance.messages = stubDeep(Object.create(discord_js.MessageManager.prototype)); instance.members = stubDeep(Object.create(discord_js.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(discord_js.Guild.prototype); guild.members = stubDeep(Object.create(discord_js.GuildMemberManager.prototype)); guild.channels = stubDeep(Object.create(discord_js.GuildChannelManager.prototype)); guild.roles = stubDeep(Object.create(discord_js.RoleManager.prototype)); guild.bans = stubDeep(Object.create(discord_js.GuildBanManager.prototype)); return stubDeep(guild); } function createMockMessage() { const instance = Object.create(discord_js.Message.prototype); const stubs = new Map(); instance.deleted = false; // Constructor-assigned — set as prototype-based stubs instance.author = stubDeep(Object.create(discord_js.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(discord_js.GuildMember.prototype)), writable: true }); Object.defineProperty(instance, 'channel', { value: stubDeep(Object.create(discord_js.TextChannel.prototype)), writable: true }); Object.defineProperty(instance, 'guild', { value: createMockGuildForMessage(), writable: true }); Object.defineProperty(instance, 'thread', { value: stubDeep(Object.create(discord_js.ThreadChannel.prototype)), writable: true }); // MessageMentions — constructor-assigned, has methods like .has(), .members instance.mentions = stubDeep(Object.create(discord_js.MessageMentions.prototype)); const alreadyDeleted = ()=>new Error('This message has already been deleted.'); stubs.set('delete', globals.jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); instance.deleted = true; })); stubs.set('edit', globals.jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); return createMockMessage(); })); stubs.set('reply', globals.jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); return createMockMessage(); })); stubs.set('react', globals.jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); })); stubs.set('pin', globals.jest.fn(async ()=>{ if (instance.deleted) throw alreadyDeleted(); })); stubs.set('unpin', globals.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(discord_js.CommandInteractionOptionResolver.prototype); base.getSubcommandGroup = globals.jest.fn((required)=>resolveSubEntry(subcommandGroup, 'subcommand group', required)); base.getSubcommand = globals.jest.fn((required)=>resolveSubEntry(subcommand, 'subcommand', required)); base.getString = globals.jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'string' ? values[name] : null, required)); base.getNumber = globals.jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'number' ? values[name] : null, required)); base.getInteger = globals.jest.fn((name, required)=>resolveOrThrow(name, typeof values[name] === 'number' ? values[name] : null, required)); base.getBoolean = globals.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 = globals.jest.fn(getObjectOption); base.getRole = globals.jest.fn(getObjectOption); base.getChannel = globals.jest.fn(getObjectOption); base.getMember = globals.jest.fn(getObjectOption); base.getMentionable = globals.jest.fn(getObjectOption); return stubDeep(base); } exports.MeoCordTestingModule = MeoCordTestingModule; exports.TestingModule = TestingModule; exports.TestingModuleBuilder = TestingModuleBuilder; exports.createChatInputOptions = createChatInputOptions; exports.createMockChannel = createMockChannel; exports.createMockClient = createMockClient; exports.createMockGuild = createMockGuild; exports.createMockInteraction = createMockInteraction; exports.createMockMessage = createMockMessage; exports.createMockUser = createMockUser;