import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ChannelRegistry } from './registry.js'; import type { ChannelAdapter, InboundMessage, OutboundMessage } from './types.js'; /** Create a mock adapter with spy functions and a triggerMessage helper. */ function createMockAdapter(name: string): ChannelAdapter & { connectFn: ReturnType; disconnectFn: ReturnType; sendFn: ReturnType; triggerMessage: (msg: InboundMessage) => void; } { let messageHandler: ((msg: InboundMessage) => void) | undefined; let _status: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected'; const connectFn = vi.fn(async () => { _status = 'connected'; }); const disconnectFn = vi.fn(async () => { _status = 'disconnected'; }); const sendFn = vi.fn(async () => {}); return { name, get status() { return _status; }, connect: connectFn, disconnect: disconnectFn, send: sendFn, onMessage: (handler: (msg: InboundMessage) => void) => { messageHandler = handler; }, triggerMessage: (msg: InboundMessage) => { messageHandler?.(msg); }, connectFn, disconnectFn, sendFn, }; } /** Create a sample inbound message for a given channel. */ function makeMessage(channel: string): InboundMessage { return { id: 'msg-1', channel, senderId: 'user-42', senderName: 'Alice', text: 'Hello', timestamp: Date.now(), }; } describe('ChannelRegistry', () => { let registry: ChannelRegistry; beforeEach(() => { registry = new ChannelRegistry(); }); it('registers and lists adapters', () => { const a1 = createMockAdapter('alpha'); const a2 = createMockAdapter('beta'); registry.register(a1); registry.register(a2); const listed = registry.list(); expect(listed).toHaveLength(2); expect(listed.map((a) => a.name)).toContain('alpha'); expect(listed.map((a) => a.name)).toContain('beta'); }); it('throws on duplicate registration', () => { const a1 = createMockAdapter('dup'); registry.register(a1); const a2 = createMockAdapter('dup'); expect(() => registry.register(a2)).toThrow('already registered'); }); it('gets adapter by name', () => { const adapter = createMockAdapter('test'); registry.register(adapter); expect(registry.get('test')).toBe(adapter); expect(registry.get('unknown')).toBeUndefined(); }); it('starts all adapters', async () => { const a1 = createMockAdapter('one'); const a2 = createMockAdapter('two'); registry.register(a1); registry.register(a2); await registry.startAll(); expect(a1.connectFn).toHaveBeenCalledOnce(); expect(a2.connectFn).toHaveBeenCalledOnce(); }); it('stops all adapters', async () => { const a1 = createMockAdapter('one'); const a2 = createMockAdapter('two'); registry.register(a1); registry.register(a2); // Connect first so they are in connected state await a1.connect(); await a2.connect(); await registry.stopAll(); expect(a1.disconnectFn).toHaveBeenCalled(); expect(a2.disconnectFn).toHaveBeenCalled(); }); it('routes inbound messages to handler', async () => { const adapter = createMockAdapter('test-channel'); registry.register(adapter); const handler = vi.fn(async (_msg: InboundMessage, reply: (r: OutboundMessage) => Promise) => { await reply({ text: 'pong' }); }); registry.setMessageHandler(handler); const msg = makeMessage('test-channel'); adapter.triggerMessage(msg); // Allow the async handler to settle await vi.waitFor(() => { expect(handler).toHaveBeenCalledOnce(); }); // Handler receives the original inbound message expect(handler.mock.calls[0][0]).toBe(msg); // The reply function should have called adapter.send with the sender's peerId expect(adapter.sendFn).toHaveBeenCalledWith('user-42', { text: 'pong' }); }); it('tracks presence from inbound messages', async () => { const adapter = createMockAdapter('test-channel'); registry.register(adapter); registry.setMessageHandler(async () => {}); adapter.triggerMessage(makeMessage('test-channel')); await vi.waitFor(() => { expect(registry.getPresence()).toHaveLength(1); }); const [presence] = registry.getPresence(); expect(presence.channel).toBe('test-channel'); expect(presence.senderId).toBe('user-42'); expect(presence.messageCount).toBe(1); expect(presence.status).toBe('online'); }); it('marks presence offline after inactivity window', async () => { vi.useFakeTimers(); try { registry = new ChannelRegistry({ offlineAfterMs: 1000 }); const adapter = createMockAdapter('test-channel'); registry.register(adapter); registry.setMessageHandler(async () => {}); adapter.triggerMessage(makeMessage('test-channel')); await vi.runAllTimersAsync(); vi.advanceTimersByTime(1500); const [presence] = registry.getPresence(); expect(presence.status).toBe('offline'); } finally { vi.useRealTimers(); } }); it('filters presence by channel and status', async () => { vi.useFakeTimers(); try { registry = new ChannelRegistry({ offlineAfterMs: 1000 }); const a1 = createMockAdapter('telegram'); const a2 = createMockAdapter('discord'); registry.register(a1); registry.register(a2); registry.setMessageHandler(async () => {}); a1.triggerMessage(makeMessage('telegram')); a2.triggerMessage({ ...makeMessage('discord'), senderId: 'user-99' }); await vi.runAllTimersAsync(); vi.advanceTimersByTime(1500); a2.triggerMessage({ ...makeMessage('discord'), senderId: 'user-99' }); const telegramOnly = registry.getPresence({ channel: 'telegram' }); expect(telegramOnly).toHaveLength(1); expect(telegramOnly[0].channel).toBe('telegram'); const onlineOnly = registry.getPresence({ status: 'online' }); expect(onlineOnly).toHaveLength(1); expect(onlineOnly[0].channel).toBe('discord'); } finally { vi.useRealTimers(); } }); it('routes reply using metadata.replyPeerId when provided', async () => { const adapter = createMockAdapter('test-channel'); registry.register(adapter); const handler = vi.fn(async (_msg: InboundMessage, reply: (r: OutboundMessage) => Promise) => { await reply({ text: 'pong' }); }); registry.setMessageHandler(handler); const msg = { ...makeMessage('test-channel'), senderId: 'isolated-run-1', metadata: { replyPeerId: 'job-a' }, }; adapter.triggerMessage(msg); await vi.waitFor(() => { expect(handler).toHaveBeenCalledOnce(); }); expect(adapter.sendFn).toHaveBeenCalledWith('job-a', { text: 'pong' }); }); it('unregisters adapter', () => { const adapter = createMockAdapter('removeme'); registry.register(adapter); registry.unregister('removeme'); expect(registry.list()).toHaveLength(0); expect(registry.get('removeme')).toBeUndefined(); }); it('unregister disconnects connected adapter', async () => { const adapter = createMockAdapter('connected-one'); registry.register(adapter); await adapter.connect(); expect(adapter.status).toBe('connected'); await registry.unregister('connected-one'); expect(adapter.disconnectFn).toHaveBeenCalled(); }); it('logs warning when no message handler set', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const adapter = createMockAdapter('no-handler'); registry.register(adapter); // Trigger a message WITHOUT calling setMessageHandler adapter.triggerMessage(makeMessage('no-handler')); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('No message handler set'), ); warnSpy.mockRestore(); }); it('handles errors in message handler gracefully', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const adapter = createMockAdapter('err-channel'); registry.register(adapter); registry.setMessageHandler(async () => { throw new Error('handler exploded'); }); // Trigger a message — should not throw adapter.triggerMessage(makeMessage('err-channel')); // Allow the async error path to settle await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining('Error handling message'), expect.any(Error), ); }); errorSpy.mockRestore(); }); });