import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Mock @slack/bolt before importing adapter ────────────────────── let capturedMessageHandler: ((args: Record) => Promise) | null = null; const mockPostMessage = vi.fn(); const mockStart = vi.fn(); const mockStop = vi.fn(); const mockAuthTest = vi.fn().mockResolvedValue({ user_id: 'UBOT123' }); const mockUsersInfo = vi.fn().mockResolvedValue({ user: { real_name: 'Test User', name: 'testuser' } }); /** Create a fresh mock Bolt App instance. */ function createMockApp() { capturedMessageHandler = null; return { message: vi.fn((handler: (args: Record) => Promise) => { capturedMessageHandler = handler; }), start: mockStart, stop: mockStop, client: { chat: { postMessage: mockPostMessage, }, auth: { test: mockAuthTest, }, users: { info: mockUsersInfo, }, }, }; } let mockApp = createMockApp(); vi.mock('@slack/bolt', () => ({ App: vi.fn().mockImplementation(() => mockApp), })); import { SlackAdapter, type SlackAdapterConfig } from './adapter.js'; import type { InboundMessage } from '../types.js'; const baseConfig: SlackAdapterConfig = { botToken: 'xoxb-test-token', appToken: 'xapp-test-token', signingSecret: 'test-signing-secret', }; /** Helper: simulate a Slack message event through the captured handler. */ async function simulateMessage(message: Record) { if (!capturedMessageHandler) throw new Error('No message handler captured — call connect() first'); await capturedMessageHandler({ message }); } describe('SlackAdapter', () => { let adapter: SlackAdapter; beforeEach(async () => { vi.clearAllMocks(); mockApp = createMockApp(); // Re-wire the App mock to return the fresh mockApp const { App } = vi.mocked(await import('@slack/bolt')); (App as unknown as ReturnType).mockImplementation(() => mockApp); adapter = new SlackAdapter(baseConfig); }); // ── Basic properties ────────────────────────────────────────── it('has name "slack"', () => { expect(adapter.name).toBe('slack'); }); it('starts as disconnected', () => { expect(adapter.status).toBe('disconnected'); }); // ── connect / disconnect ────────────────────────────────────── it('connect creates Bolt App with Socket Mode and sets connected status', async () => { await adapter.connect(); expect(adapter.status).toBe('connected'); const { App } = await import('@slack/bolt'); expect(App).toHaveBeenCalledWith({ token: 'xoxb-test-token', appToken: 'xapp-test-token', signingSecret: 'test-signing-secret', socketMode: true, }); expect(mockStart).toHaveBeenCalledTimes(1); }); it('connect registers message handler via app.message()', async () => { await adapter.connect(); expect(mockApp.message).toHaveBeenCalledTimes(1); expect(capturedMessageHandler).toBeTypeOf('function'); }); it('disconnect stops app and sets disconnected', async () => { await adapter.connect(); expect(adapter.status).toBe('connected'); await adapter.disconnect(); expect(mockStop).toHaveBeenCalledTimes(1); expect(adapter.status).toBe('disconnected'); }); it('disconnect is safe when not connected', async () => { await adapter.disconnect(); expect(adapter.status).toBe('disconnected'); // No app to stop — should not throw }); // ── send ────────────────────────────────────────────────────── it('send throws when not connected', async () => { await expect(adapter.send('C123:1234.5678', { text: 'hello' })).rejects.toThrow( 'Slack adapter not connected', ); }); it('send delivers a short message with correct channel and thread_ts', async () => { await adapter.connect(); await adapter.send('C123:1234.5678', { text: 'Hello there' }); expect(mockPostMessage).toHaveBeenCalledTimes(1); expect(mockPostMessage).toHaveBeenCalledWith({ channel: 'C123', text: 'Hello there', thread_ts: '1234.5678', }); }); it('send chunks long messages (>4000 chars)', async () => { await adapter.connect(); // Create a message longer than 4000 chars — two halves joined by a newline const half = 'A'.repeat(3000); const longMessage = `${half}\n${'B'.repeat(3000)}`; await adapter.send('C123:1234.5678', { text: longMessage }); // Should have been split into at least 2 chunks expect(mockPostMessage.mock.calls.length).toBeGreaterThanOrEqual(2); // All calls should target the same channel and thread for (const call of mockPostMessage.mock.calls) { expect(call[0].channel).toBe('C123'); expect(call[0].thread_ts).toBe('1234.5678'); } }); it('send throws on invalid peer ID format (no colon)', async () => { await adapter.connect(); await expect(adapter.send('invalid-no-colon', { text: 'hello' })).rejects.toThrow( 'Invalid peer ID format: invalid-no-colon', ); }); // ── onMessage / inbound handling ────────────────────────────── it('inbound message triggers handler with correct peerId (channelId:threadTs)', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', thread_ts: '1111.0000', channel: 'C123', user: 'U456', text: 'Hello Flynn', }); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.channel).toBe('slack'); expect(msg.senderId).toBe('C123:1111.0000'); expect(msg.senderName).toBe('Test User'); expect(msg.text).toBe('Hello Flynn'); expect(msg.id).toBe('1234.5678'); }); it('inbound message uses thread_ts for peer ID when in thread', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '2222.3333', thread_ts: '1111.0000', channel: 'C123', user: 'U456', text: 'Threaded reply', }); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.senderId).toBe('C123:1111.0000'); }); it('inbound message falls back to ts when no thread_ts', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '2222.3333', channel: 'C123', user: 'U456', text: 'Top-level message', }); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.senderId).toBe('C123:2222.3333'); }); it('ignores bot messages (bot_id present)', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: 'Bot message', bot_id: 'B789', }); expect(handler).not.toHaveBeenCalled(); }); it('ignores bot messages (subtype === "bot_message")', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: 'Bot message', subtype: 'bot_message', }); expect(handler).not.toHaveBeenCalled(); }); it('ignores messages in disallowed channels', async () => { const restrictedAdapter = new SlackAdapter({ ...baseConfig, allowedChannelIds: ['C-allowed'], }); const handler = vi.fn(); restrictedAdapter.onMessage(handler); await restrictedAdapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C-not-allowed', user: 'U456', text: 'Hello', }); expect(handler).not.toHaveBeenCalled(); }); it('strips bot mentions (<@U\\w+> pattern)', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: '<@UBOT123> What is the weather?', }); 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(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: '!reset', }); 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('"reset" text (without !) delivers reset metadata', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: 'reset', }); 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('does nothing when no message handler is registered', async () => { // Don't call onMessage — no handler registered await adapter.connect(); // Should not throw await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: 'Hello', }); }); it('messages with no text property are handled (empty string)', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', // no text property }); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe(''); }); it('allowed channel messages pass through when allowedChannelIds configured', async () => { const restrictedAdapter = new SlackAdapter({ ...baseConfig, allowedChannelIds: ['C-allowed'], }); const handler = vi.fn(); restrictedAdapter.onMessage(handler); await restrictedAdapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C-allowed', user: 'U456', text: 'Hello', }); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Hello'); }); // ── Mention gating ───────────────────────────────────────────── it('ignores messages without mention when requireMention is true', async () => { const mentionAdapter = new SlackAdapter({ ...baseConfig, requireMention: true, }); const handler = vi.fn(); mentionAdapter.onMessage(handler); await mentionAdapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: 'Hello everyone', }); expect(handler).not.toHaveBeenCalled(); }); it('processes messages with bot mention when requireMention is true', async () => { const mentionAdapter = new SlackAdapter({ ...baseConfig, requireMention: true, }); const handler = vi.fn(); mentionAdapter.onMessage(handler); await mentionAdapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: '<@UBOT123> What is the weather?', }); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('What is the weather?'); }); it('processes all messages when requireMention is false (default)', async () => { const handler = vi.fn(); adapter.onMessage(handler); await adapter.connect(); await simulateMessage({ ts: '1234.5678', channel: 'C123', user: 'U456', text: 'Hello without mention', }); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Hello without mention'); }); });