429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
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<string, ((...args: unknown[]) => 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<string, unknown> = {}) {
|
|
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<string, unknown> = {}) {
|
|
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<typeof vi.fn>).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!');
|
|
});
|
|
});
|