285 lines
8.4 KiB
TypeScript
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();
|
|
});
|
|
});
|