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
JavaScript
;
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;