Files
flynn/src/channels/registry.test.ts
T
2026-02-15 19:28:16 -08:00

285 lines
8.4 KiB
TypeScript

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<typeof vi.fn>;
disconnectFn: ReturnType<typeof vi.fn>;
sendFn: ReturnType<typeof vi.fn>;
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<void>) => {
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<void>) => {
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();
});
});