test(channels): add comprehensive test suites for channel adapters

- Add ChannelBase, GroupGate, SenderGate, SessionRouter tests
- Add DingTalk markdown utility tests
- Add Weixin media and send helper tests
- Add CLI channel config-utils and pidfile tests
- Configure vitest for all channel packages
- Exclude test files from TypeScript build

Tests cover attachment handling, block streaming, gating policies,
session routing, markdown conversion, config parsing, and service management.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-27 15:17:55 +00:00
parent 39103eea5f
commit d84675e86f
17 changed files with 1479 additions and 3 deletions

View file

@ -0,0 +1,397 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'node:events';
import type { ChannelConfig, Envelope } from './types.js';
import type { AcpBridge } from './AcpBridge.js';
import { ChannelBase } from './ChannelBase.js';
import type { ChannelBaseOptions } from './ChannelBase.js';
// Concrete test implementation
class TestChannel extends ChannelBase {
sent: Array<{ chatId: string; text: string }> = [];
connected = false;
async connect() {
this.connected = true;
}
async sendMessage(chatId: string, text: string) {
this.sent.push({ chatId, text });
}
disconnect() {
this.connected = false;
}
}
function createBridge(): AcpBridge {
const emitter = new EventEmitter();
let sessionCounter = 0;
const bridge = Object.assign(emitter, {
newSession: vi.fn().mockImplementation(() => `s-${++sessionCounter}`),
loadSession: vi.fn(),
prompt: vi.fn().mockResolvedValue('agent response'),
stop: vi.fn(),
start: vi.fn(),
isConnected: true,
availableCommands: [],
setBridge: vi.fn(),
});
return bridge as unknown as AcpBridge;
}
function defaultConfig(overrides: Partial<ChannelConfig> = {}): ChannelConfig {
return {
type: 'test',
token: 'tok',
senderPolicy: 'open',
allowedUsers: [],
sessionScope: 'user',
cwd: '/tmp',
groupPolicy: 'disabled',
groups: {},
...overrides,
};
}
function envelope(overrides: Partial<Envelope> = {}): Envelope {
return {
channelName: 'test-chan',
senderId: 'user1',
senderName: 'User 1',
chatId: 'chat1',
text: 'hello',
isGroup: false,
isMentioned: false,
isReplyToBot: false,
...overrides,
};
}
describe('ChannelBase', () => {
let bridge: AcpBridge;
beforeEach(() => {
bridge = createBridge();
});
function createChannel(
configOverrides: Partial<ChannelConfig> = {},
options?: ChannelBaseOptions,
): TestChannel {
return new TestChannel(
'test-chan',
defaultConfig(configOverrides),
bridge,
options,
);
}
describe('gate integration', () => {
it('silently drops group messages when groupPolicy=disabled', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ isGroup: true }));
expect(ch.sent).toEqual([]);
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('allows DM messages through', async () => {
const ch = createChannel();
await ch.handleInbound(envelope());
expect(bridge.prompt).toHaveBeenCalled();
});
it('rejects sender with allowlist policy', async () => {
const ch = createChannel({
senderPolicy: 'allowlist',
allowedUsers: ['admin'],
});
await ch.handleInbound(envelope({ senderId: 'stranger' }));
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('allows sender on allowlist', async () => {
const ch = createChannel({
senderPolicy: 'allowlist',
allowedUsers: ['user1'],
});
await ch.handleInbound(envelope());
expect(bridge.prompt).toHaveBeenCalled();
});
});
describe('slash commands', () => {
it('/help sends command list', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: '/help' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toContain('/help');
expect(ch.sent[0]!.text).toContain('/clear');
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('/clear removes session and confirms', async () => {
const ch = createChannel();
// Create a session first
await ch.handleInbound(envelope());
ch.sent = [];
// Now clear
await ch.handleInbound(envelope({ text: '/clear' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toContain('Session cleared');
});
it('/clear reports when no session exists', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: '/clear' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toContain('No active session');
});
it('/reset and /new are aliases for /clear', async () => {
for (const cmd of ['/reset', '/new']) {
const ch = createChannel();
await ch.handleInbound(envelope());
ch.sent = [];
await ch.handleInbound(envelope({ text: cmd }));
expect(ch.sent[0]!.text).toContain('Session cleared');
}
});
it('/status shows session info', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: '/status' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toContain('Session: none');
expect(ch.sent[0]!.text).toContain('Access: open');
expect(ch.sent[0]!.text).toContain('Channel: test-chan');
});
it('/status shows active session', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: 'hi' }));
ch.sent = [];
await ch.handleInbound(envelope({ text: '/status' }));
expect(ch.sent[0]!.text).toContain('Session: active');
});
it('handles /command@botname format', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: '/help@mybot' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toContain('/help');
});
it('forwards unrecognized commands to agent', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: '/unknown' }));
expect(bridge.prompt).toHaveBeenCalled();
});
});
describe('custom commands', () => {
it('subclass can register custom commands', async () => {
const ch = createChannel();
// Access protected method via the test subclass
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ch as any).registerCommand('ping', async () => {
await ch.sendMessage('chat1', 'pong');
return true;
});
await ch.handleInbound(envelope({ text: '/ping' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toBe('pong');
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('/help shows platform-specific commands', async () => {
const ch = createChannel();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ch as any).registerCommand('start', async () => true);
await ch.handleInbound(envelope({ text: '/help' }));
expect(ch.sent[0]!.text).toContain('/start');
});
});
describe('message enrichment', () => {
it('prepends referenced text', async () => {
const ch = createChannel();
await ch.handleInbound(
envelope({ text: 'my reply', referencedText: 'original message' }),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promptText = (bridge.prompt as any).mock.calls[0][1] as string;
expect(promptText).toContain('[Replying to: "original message"]');
expect(promptText).toContain('my reply');
});
it('appends file paths from attachments', async () => {
const ch = createChannel();
await ch.handleInbound(
envelope({
text: 'check this',
attachments: [
{
type: 'file',
filePath: '/tmp/test.pdf',
mimeType: 'application/pdf',
fileName: 'test.pdf',
},
],
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promptText = (bridge.prompt as any).mock.calls[0][1] as string;
expect(promptText).toContain('/tmp/test.pdf');
expect(promptText).toContain('"test.pdf"');
});
it('extracts image from attachments', async () => {
const ch = createChannel();
await ch.handleInbound(
envelope({
text: 'see image',
attachments: [
{
type: 'image',
data: 'base64data',
mimeType: 'image/png',
},
],
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = (bridge.prompt as any).mock.calls[0][2];
expect(options.imageBase64).toBe('base64data');
expect(options.imageMimeType).toBe('image/png');
});
it('uses legacy imageBase64 when no attachment image', async () => {
const ch = createChannel();
await ch.handleInbound(
envelope({
text: 'see image',
imageBase64: 'legacydata',
imageMimeType: 'image/jpeg',
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = (bridge.prompt as any).mock.calls[0][2];
expect(options.imageBase64).toBe('legacydata');
});
it('prepends instructions on first message only', async () => {
const ch = createChannel({ instructions: 'Be concise.' });
await ch.handleInbound(envelope({ text: 'first' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const firstPrompt = (bridge.prompt as any).mock.calls[0][1] as string;
expect(firstPrompt).toContain('Be concise.');
await ch.handleInbound(envelope({ text: 'second' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondPrompt = (bridge.prompt as any).mock.calls[1][1] as string;
expect(secondPrompt).not.toContain('Be concise.');
});
});
describe('session routing', () => {
it('creates new session on first message', async () => {
const ch = createChannel();
await ch.handleInbound(envelope());
expect(bridge.newSession).toHaveBeenCalledTimes(1);
});
it('reuses session for same sender', async () => {
const ch = createChannel();
await ch.handleInbound(envelope());
await ch.handleInbound(envelope());
expect(bridge.newSession).toHaveBeenCalledTimes(1);
});
it('creates separate sessions for different senders', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ senderId: 'alice' }));
await ch.handleInbound(envelope({ senderId: 'bob' }));
expect(bridge.newSession).toHaveBeenCalledTimes(2);
});
});
describe('response delivery', () => {
it('sends agent response via sendMessage', async () => {
const ch = createChannel();
await ch.handleInbound(envelope());
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toBe('agent response');
});
it('does not send when agent returns empty response', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bridge.prompt as any).mockResolvedValue('');
const ch = createChannel();
await ch.handleInbound(envelope());
expect(ch.sent).toEqual([]);
});
});
describe('block streaming', () => {
it('uses block streamer when blockStreaming=on', async () => {
// The streamer sends blocks; onResponseComplete is NOT called
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bridge.prompt as any).mockImplementation(
(sid: string, _text: string) => {
// Simulate streaming chunks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bridge as any).emit('textChunk', sid, 'Hello world! ');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bridge as any).emit('textChunk', sid, 'This is a test.');
return Promise.resolve('Hello world! This is a test.');
},
);
const ch = createChannel({
blockStreaming: 'on',
blockStreamingChunk: { minChars: 5, maxChars: 100 },
blockStreamingCoalesce: { idleMs: 0 },
});
await ch.handleInbound(envelope());
// BlockStreamer flush should have sent the accumulated text
expect(ch.sent.length).toBeGreaterThanOrEqual(1);
});
});
describe('pairing flow', () => {
it('sends pairing code message when required', async () => {
const ch = createChannel({ senderPolicy: 'pairing', allowedUsers: [] });
await ch.handleInbound(envelope({ senderId: 'stranger' }));
expect(ch.sent).toHaveLength(1);
expect(ch.sent[0]!.text).toContain('pairing code');
expect(bridge.prompt).not.toHaveBeenCalled();
});
});
describe('setBridge', () => {
it('replaces the bridge instance', async () => {
const ch = createChannel();
const newBridge = createBridge();
ch.setBridge(newBridge);
// The channel should use the new bridge for future messages
// (this mainly ensures no crash)
expect(() => ch.setBridge(newBridge)).not.toThrow();
});
});
describe('isLocalCommand', () => {
it('returns true for registered commands', () => {
const ch = createChannel();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((ch as any).isLocalCommand('/help')).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((ch as any).isLocalCommand('/clear')).toBe(true);
});
it('returns false for non-commands', () => {
const ch = createChannel();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((ch as any).isLocalCommand('hello')).toBe(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((ch as any).isLocalCommand('/unknown')).toBe(false);
});
});
});

View file

@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { GroupGate } from './GroupGate.js';
import type { Envelope } from './types.js';
function envelope(overrides: Partial<Envelope> = {}): Envelope {
return {
channelName: 'test',
senderId: 'user1',
senderName: 'User',
chatId: 'chat1',
text: 'hello',
isGroup: false,
isMentioned: false,
isReplyToBot: false,
...overrides,
};
}
describe('GroupGate', () => {
describe('non-group messages', () => {
it('always allows DM messages regardless of policy', () => {
for (const policy of ['disabled', 'allowlist', 'open'] as const) {
const gate = new GroupGate(policy);
expect(gate.check(envelope()).allowed).toBe(true);
}
});
});
describe('disabled policy', () => {
it('rejects all group messages', () => {
const gate = new GroupGate('disabled');
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'disabled' });
});
});
describe('allowlist policy', () => {
it('rejects groups not in allowlist', () => {
const gate = new GroupGate('allowlist', { other: {} });
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' });
});
it('does not treat "*" as wildcard allow', () => {
const gate = new GroupGate('allowlist', { '*': {} });
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' });
});
it('allows explicitly listed group with mention', () => {
const gate = new GroupGate('allowlist', { chat1: {} });
const result = gate.check(envelope({ isGroup: true, isMentioned: true }));
expect(result.allowed).toBe(true);
});
it('requires mention by default for allowlisted group', () => {
const gate = new GroupGate('allowlist', { chat1: {} });
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'mention_required' });
});
it('allows reply-to-bot as alternative to mention', () => {
const gate = new GroupGate('allowlist', { chat1: {} });
const result = gate.check(
envelope({ isGroup: true, isReplyToBot: true }),
);
expect(result.allowed).toBe(true);
});
it('respects requireMention=false override', () => {
const gate = new GroupGate('allowlist', {
chat1: { requireMention: false },
});
const result = gate.check(envelope({ isGroup: true }));
expect(result.allowed).toBe(true);
});
});
describe('open policy', () => {
it('allows any group with mention', () => {
const gate = new GroupGate('open');
const result = gate.check(envelope({ isGroup: true, isMentioned: true }));
expect(result.allowed).toBe(true);
});
it('requires mention by default', () => {
const gate = new GroupGate('open');
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'mention_required' });
});
it('uses "*" as default config fallback', () => {
const gate = new GroupGate('open', { '*': { requireMention: false } });
const result = gate.check(envelope({ isGroup: true }));
expect(result.allowed).toBe(true);
});
it('per-group config overrides "*" default', () => {
const gate = new GroupGate('open', {
'*': { requireMention: false },
chat1: { requireMention: true },
});
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'mention_required' });
});
});
describe('defaults', () => {
it('defaults to disabled policy', () => {
const gate = new GroupGate();
const result = gate.check(envelope({ isGroup: true }));
expect(result).toEqual({ allowed: false, reason: 'disabled' });
});
});
});

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
import { SenderGate } from './SenderGate.js';
import type { PairingStore } from './PairingStore.js';
function mockPairingStore(overrides: Partial<PairingStore> = {}): PairingStore {
return {
isApproved: vi.fn().mockReturnValue(false),
createRequest: vi.fn().mockReturnValue('ABCD1234'),
approve: vi.fn(),
listPending: vi.fn().mockReturnValue([]),
getAllowlist: vi.fn().mockReturnValue([]),
...overrides,
} as unknown as PairingStore;
}
describe('SenderGate', () => {
describe('open policy', () => {
it('allows any sender', () => {
const gate = new SenderGate('open');
expect(gate.check('anyone').allowed).toBe(true);
});
});
describe('allowlist policy', () => {
it('allows listed users', () => {
const gate = new SenderGate('allowlist', ['alice', 'bob']);
expect(gate.check('alice').allowed).toBe(true);
});
it('rejects unlisted users', () => {
const gate = new SenderGate('allowlist', ['alice']);
const result = gate.check('eve');
expect(result.allowed).toBe(false);
expect(result.pairingCode).toBeUndefined();
});
it('works with empty allowlist', () => {
const gate = new SenderGate('allowlist');
expect(gate.check('anyone').allowed).toBe(false);
});
});
describe('pairing policy', () => {
it('allows static allowlisted users without checking store', () => {
const store = mockPairingStore();
const gate = new SenderGate('pairing', ['admin'], store);
const result = gate.check('admin');
expect(result.allowed).toBe(true);
expect(store.isApproved).not.toHaveBeenCalled();
});
it('allows dynamically approved users', () => {
const store = mockPairingStore({
isApproved: vi.fn().mockReturnValue(true),
});
const gate = new SenderGate('pairing', [], store);
expect(gate.check('user1').allowed).toBe(true);
});
it('generates pairing code for unknown sender', () => {
const store = mockPairingStore({
createRequest: vi.fn().mockReturnValue('XYZW5678'),
});
const gate = new SenderGate('pairing', [], store);
const result = gate.check('stranger', 'Stranger Name');
expect(result.allowed).toBe(false);
expect(result.pairingCode).toBe('XYZW5678');
expect(store.createRequest).toHaveBeenCalledWith(
'stranger',
'Stranger Name',
);
});
it('returns null pairingCode when cap reached', () => {
const store = mockPairingStore({
createRequest: vi.fn().mockReturnValue(null),
});
const gate = new SenderGate('pairing', [], store);
const result = gate.check('stranger');
expect(result.allowed).toBe(false);
expect(result.pairingCode).toBeNull();
});
it('uses senderId as senderName fallback', () => {
const store = mockPairingStore();
const gate = new SenderGate('pairing', [], store);
gate.check('user42');
expect(store.createRequest).toHaveBeenCalledWith('user42', 'user42');
});
it('works without pairing store (no store provided)', () => {
const gate = new SenderGate('pairing');
const result = gate.check('anyone');
expect(result.allowed).toBe(false);
expect(result.pairingCode).toBeNull();
});
});
describe('unknown policy', () => {
it('throws on unknown policy', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const gate = new SenderGate('unknown' as any);
expect(() => gate.check('user')).toThrow('Unknown sender policy');
});
});
});

View file

@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionRouter } from './SessionRouter.js';
import type { AcpBridge } from './AcpBridge.js';
let sessionCounter = 0;
function mockBridge(): AcpBridge {
return {
newSession: vi.fn().mockImplementation(() => `session-${++sessionCounter}`),
loadSession: vi.fn().mockImplementation((id: string) => id),
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
availableCommands: [],
} as unknown as AcpBridge;
}
describe('SessionRouter', () => {
let bridge: AcpBridge;
beforeEach(() => {
sessionCounter = 0;
bridge = mockBridge();
});
describe('routing key scopes', () => {
it('user scope: routes by channel + sender + chat', async () => {
const router = new SessionRouter(bridge, '/tmp');
const s1 = await router.resolve('ch', 'alice', 'chat1');
const s2 = await router.resolve('ch', 'alice', 'chat2');
const s3 = await router.resolve('ch', 'bob', 'chat1');
expect(new Set([s1, s2, s3]).size).toBe(3);
});
it('user scope: same sender+chat reuses session', async () => {
const router = new SessionRouter(bridge, '/tmp');
const s1 = await router.resolve('ch', 'alice', 'chat1');
const s2 = await router.resolve('ch', 'alice', 'chat1');
expect(s1).toBe(s2);
expect(bridge.newSession).toHaveBeenCalledTimes(1);
});
it('thread scope: routes by channel + threadId', async () => {
const router = new SessionRouter(bridge, '/tmp', 'thread');
const s1 = await router.resolve('ch', 'alice', 'chat1', 'thread1');
const s2 = await router.resolve('ch', 'bob', 'chat1', 'thread1');
expect(s1).toBe(s2); // same thread = same session
});
it('thread scope: falls back to chatId when no threadId', async () => {
const router = new SessionRouter(bridge, '/tmp', 'thread');
const s1 = await router.resolve('ch', 'alice', 'chat1');
const s2 = await router.resolve('ch', 'bob', 'chat1');
expect(s1).toBe(s2);
});
it('single scope: all messages share one session per channel', async () => {
const router = new SessionRouter(bridge, '/tmp', 'single');
const s1 = await router.resolve('ch', 'alice', 'chat1');
const s2 = await router.resolve('ch', 'bob', 'chat2');
expect(s1).toBe(s2);
});
it('single scope: different channels get different sessions', async () => {
const router = new SessionRouter(bridge, '/tmp', 'single');
const s1 = await router.resolve('ch1', 'alice', 'chat1');
const s2 = await router.resolve('ch2', 'alice', 'chat1');
expect(s1).not.toBe(s2);
});
});
describe('resolve', () => {
it('passes cwd to bridge.newSession', async () => {
const router = new SessionRouter(bridge, '/default');
await router.resolve('ch', 'alice', 'chat1', undefined, '/custom');
expect(bridge.newSession).toHaveBeenCalledWith('/custom');
});
it('uses defaultCwd when no cwd provided', async () => {
const router = new SessionRouter(bridge, '/default');
await router.resolve('ch', 'alice', 'chat1');
expect(bridge.newSession).toHaveBeenCalledWith('/default');
});
});
describe('getTarget', () => {
it('returns target for existing session', async () => {
const router = new SessionRouter(bridge, '/tmp');
const sid = await router.resolve('ch', 'alice', 'chat1', 'thread1');
const target = router.getTarget(sid);
expect(target).toEqual({
channelName: 'ch',
senderId: 'alice',
chatId: 'chat1',
threadId: 'thread1',
});
});
it('returns undefined for unknown session', () => {
const router = new SessionRouter(bridge, '/tmp');
expect(router.getTarget('nonexistent')).toBeUndefined();
});
});
describe('hasSession', () => {
it('returns true for existing session with chatId', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
expect(router.hasSession('ch', 'alice', 'chat1')).toBe(true);
});
it('returns false for non-existing session', () => {
const router = new SessionRouter(bridge, '/tmp');
expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false);
});
it('prefix-scans when chatId omitted', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
expect(router.hasSession('ch', 'alice')).toBe(true);
expect(router.hasSession('ch', 'bob')).toBe(false);
});
});
describe('removeSession', () => {
it('removes session by key and returns true', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
expect(router.removeSession('ch', 'alice', 'chat1')).toBe(true);
expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false);
});
it('returns false when nothing to remove', () => {
const router = new SessionRouter(bridge, '/tmp');
expect(router.removeSession('ch', 'alice', 'chat1')).toBe(false);
});
it('removes all sender sessions when chatId omitted', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
await router.resolve('ch', 'alice', 'chat2');
expect(router.removeSession('ch', 'alice')).toBe(true);
expect(router.hasSession('ch', 'alice')).toBe(false);
});
it('cleans up target mapping after removal', async () => {
const router = new SessionRouter(bridge, '/tmp');
const sid = await router.resolve('ch', 'alice', 'chat1');
router.removeSession('ch', 'alice', 'chat1');
expect(router.getTarget(sid)).toBeUndefined();
});
});
describe('getAll', () => {
it('returns all session entries', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
await router.resolve('ch', 'bob', 'chat2');
const all = router.getAll();
expect(all).toHaveLength(2);
expect(all.map((e) => e.target.senderId).sort()).toEqual([
'alice',
'bob',
]);
});
it('returns empty array when no sessions', () => {
const router = new SessionRouter(bridge, '/tmp');
expect(router.getAll()).toEqual([]);
});
});
describe('clearAll', () => {
it('clears all in-memory state', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
router.clearAll();
expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false);
expect(router.getAll()).toEqual([]);
});
});
describe('setBridge', () => {
it('replaces the bridge instance', async () => {
const router = new SessionRouter(bridge, '/tmp');
const newBridge = mockBridge();
router.setBridge(newBridge);
await router.resolve('ch', 'alice', 'chat1');
expect(newBridge.newSession).toHaveBeenCalled();
expect(bridge.newSession).not.toHaveBeenCalled();
});
});
});