import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Mock discord.js before importing adapter ────────────────────── /** Map of event name → handler function for the mock client. */ type HandlerMap = Map void)[]>; const mockChannelSend = vi.fn(); /** Create a fresh mock client instance. */ function createMockClient() { const handlers: HandlerMap = new Map(); return { _handlers: handlers, user: null as { id: string; tag: string } | null, on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { if (!handlers.has(event)) {handlers.set(event, []);} const eventHandlers = handlers.get(event); if (eventHandlers) { eventHandlers.push(handler); } }), login: vi.fn(async (_token: string) => { // Set user info after login mockClient.user = { id: '123456789', tag: 'TestBot#0001' }; // Trigger ready event asynchronously setTimeout(() => { const readyHandlers = handlers.get('ready') ?? []; for (const h of readyHandlers) {h();} }, 0); }), destroy: vi.fn(), channels: { fetch: vi.fn(async (_id: string) => ({ send: mockChannelSend, })), }, }; } let mockClient = createMockClient(); vi.mock('discord.js', () => ({ Client: vi.fn().mockImplementation(() => mockClient), GatewayIntentBits: { Guilds: 1, GuildMessages: 2, MessageContent: 4, DirectMessages: 8, }, Events: { ClientReady: 'ready', MessageCreate: 'messageCreate', }, })); import { DiscordAdapter, type DiscordAdapterConfig } from './adapter.js'; import type { InboundMessage } from '../types.js'; const baseConfig: DiscordAdapterConfig = { botToken: 'test-discord-token', }; /** Helper: emit a mock event on the client. */ function emitEvent(eventName: string, ...args: unknown[]) { const eventHandlers = mockClient._handlers.get(eventName) ?? []; for (const handler of eventHandlers) { handler(...args); } } /** Helper: create a mock Discord message for guild channels. */ function createGuildMessage(overrides: Record = {}) { return { id: 'msg-1', content: 'Hello Flynn', author: { bot: false, username: 'TestUser' }, guild: { id: 'guild-1' }, channelId: 'channel-1', mentions: { has: vi.fn().mockReturnValue(false), }, ...overrides, }; } /** Helper: create a mock Discord DM message. */ function createDMMessage(overrides: Record = {}) { return { id: 'dm-1', content: 'Hello from DM', author: { bot: false, username: 'DMUser' }, guild: null, channelId: 'dm-channel-1', mentions: { has: vi.fn().mockReturnValue(false), }, ...overrides, }; } describe('DiscordAdapter', () => { let adapter: DiscordAdapter; beforeEach(async () => { vi.clearAllMocks(); mockClient = createMockClient(); // Re-wire the Client mock to return the fresh mockClient const { Client } = vi.mocked(await import('discord.js')); (Client as unknown as ReturnType).mockImplementation(() => mockClient); adapter = new DiscordAdapter(baseConfig); }); // ── Basic properties ────────────────────────────────────────── it('has name "discord"', () => { expect(adapter.name).toBe('discord'); }); it('starts as disconnected', () => { expect(adapter.status).toBe('disconnected'); }); // ── connect / disconnect ────────────────────────────────────── it('connect creates client and sets connected status', async () => { await adapter.connect(); expect(adapter.status).toBe('connected'); const { Client } = await import('discord.js'); expect(Client).toHaveBeenCalledWith({ intents: [1, 2, 4, 8], }); expect(mockClient.login).toHaveBeenCalledWith('test-discord-token'); }); it('connect registers messageCreate handler', async () => { await adapter.connect(); // Should have registered handlers for 'ready' and 'messageCreate' const eventNames = Array.from(mockClient._handlers.keys()); expect(eventNames).toContain('ready'); expect(eventNames).toContain('messageCreate'); }); it('disconnect destroys client and sets disconnected', async () => { await adapter.connect(); expect(adapter.status).toBe('connected'); await adapter.disconnect(); expect(mockClient.destroy).toHaveBeenCalledTimes(1); expect(adapter.status).toBe('disconnected'); }); it('disconnect is safe when not connected', async () => { await adapter.disconnect(); expect(adapter.status).toBe('disconnected'); // No client to destroy — should not throw }); // ── send ────────────────────────────────────────────────────── it('send throws when not connected', async () => { await expect(adapter.send('channel-1', { text: 'hello' })).rejects.toThrow( 'Discord adapter not connected', ); }); it('send delivers a short message', async () => { await adapter.connect(); await adapter.send('channel-1', { text: 'Hello there' }); expect(mockClient.channels.fetch).toHaveBeenCalledWith('channel-1'); expect(mockChannelSend).toHaveBeenCalledTimes(1); expect(mockChannelSend).toHaveBeenCalledWith('Hello there'); }); it('send chunks long messages (>2000 chars)', async () => { await adapter.connect(); // Create a message longer than 2000 chars — two halves joined by a newline const half = 'A'.repeat(1500); const longMessage = `${half}\n${'B'.repeat(1500)}`; await adapter.send('channel-1', { text: longMessage }); // Should have been split into at least 2 chunks expect(mockChannelSend.mock.calls.length).toBeGreaterThanOrEqual(2); }); // ── onMessage / inbound handling ────────────────────────────── it('inbound message from guild with mention triggers handler', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createGuildMessage({ content: '<@123456789> Hello Flynn', mentions: { has: vi.fn().mockReturnValue(true) }, }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.channel).toBe('discord'); expect(msg.senderId).toBe('channel-1'); expect(msg.senderName).toBe('TestUser'); expect(msg.text).toBe('Hello Flynn'); }); it('inbound message from DM triggers handler without mention', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createDMMessage(); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.channel).toBe('discord'); expect(msg.senderId).toBe('dm-channel-1'); expect(msg.senderName).toBe('DMUser'); expect(msg.text).toBe('Hello from DM'); }); it('ignores bot messages (author.bot = true)', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createDMMessage({ author: { bot: true, username: 'SomeBot' }, }); emitEvent('messageCreate', message); expect(handler).not.toHaveBeenCalled(); }); it('ignores messages in disallowed guilds', async () => { const restrictedAdapter = new DiscordAdapter({ ...baseConfig, allowedGuildIds: ['guild-allowed'], }); const handler = vi.fn(); restrictedAdapter.onMessage(handler); await restrictedAdapter.connect(); const message = createGuildMessage({ guild: { id: 'guild-not-allowed' }, mentions: { has: vi.fn().mockReturnValue(true) }, }); emitEvent('messageCreate', message); expect(handler).not.toHaveBeenCalled(); }); it('ignores messages in disallowed channels', async () => { const restrictedAdapter = new DiscordAdapter({ ...baseConfig, allowedChannelIds: ['channel-allowed'], requireMention: false, }); const handler = vi.fn(); restrictedAdapter.onMessage(handler); await restrictedAdapter.connect(); const message = createGuildMessage({ channelId: 'channel-not-allowed', }); emitEvent('messageCreate', message); expect(handler).not.toHaveBeenCalled(); }); it('strips bot mention from message text', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createGuildMessage({ content: '<@123456789> What is the weather?', mentions: { has: vi.fn().mockReturnValue(true) }, }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('What is the weather?'); }); it('!reset command delivers reset metadata', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createDMMessage({ content: '!reset', }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('!reset'); expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); }); it('guild message without mention is ignored when requireMention is true', async () => { // Default config has requireMention undefined (defaults to true) const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createGuildMessage({ mentions: { has: vi.fn().mockReturnValue(false) }, }); emitEvent('messageCreate', message); expect(handler).not.toHaveBeenCalled(); }); // ── Additional edge cases ───────────────────────────────────── it('guild message without mention is accepted when requireMention is false', async () => { const noMentionAdapter = new DiscordAdapter({ ...baseConfig, requireMention: false, }); const handler = vi.fn(); noMentionAdapter.onMessage(handler); await noMentionAdapter.connect(); const message = createGuildMessage({ mentions: { has: vi.fn().mockReturnValue(false) }, }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); }); it('does nothing when no message handler is registered', async () => { // Don't call onMessage — no handler registered await adapter.connect(); const message = createDMMessage(); // Should not throw emitEvent('messageCreate', message); }); it('"reset" text (without !) delivers reset metadata in DMs', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createDMMessage({ content: 'reset', }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); }); it('strips mention and recognizes reset command after mention', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); const message = createGuildMessage({ content: '<@123456789> reset', mentions: { has: vi.fn().mockReturnValue(true) }, }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); }); it('allowed guild messages with mention pass through', async () => { const restrictedAdapter = new DiscordAdapter({ ...baseConfig, allowedGuildIds: ['guild-1'], allowedChannelIds: ['channel-1'], }); const handler = vi.fn(); restrictedAdapter.onMessage(handler); await restrictedAdapter.connect(); const message = createGuildMessage({ content: '<@123456789> Hello!', guild: { id: 'guild-1' }, channelId: 'channel-1', mentions: { has: vi.fn().mockReturnValue(true) }, }); emitEvent('messageCreate', message); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Hello!'); }); });