import fs from 'fs'; import os from 'os'; import path from 'path'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; // --- Mocks --- // Mock registry (registerChannel runs at import time) const registeredChannelFactories = vi.hoisted( () => new Map any>(), ); vi.mock('./registry.js', () => ({ registerChannel: vi.fn((name: string, factory: (...args: any[]) => any) => { registeredChannelFactories.set(name, factory); }), })); // Mock env reader (used by the factory, not needed in unit tests) vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})), getEnv: vi.fn(() => undefined), })); // Mock config vi.mock('../config.js', () => ({ ASSISTANT_NAME: 'Andy', TRIGGER_PATTERN: /^@Andy\b/i, DATA_DIR: '/tmp/ejclaw-test-data', CACHE_DIR: '/tmp/ejclaw-test-cache', STATUS_CHANNEL_ID: 'status-channel', })); // Mock logger vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); const hasReviewerLeaseMock = vi.hoisted(() => vi.fn(() => false)); vi.mock('../service-routing.js', () => ({ hasReviewerLease: hasReviewerLeaseMock, })); // --- discord.js mock --- type Handler = (...args: any[]) => any; const clientRef = vi.hoisted(() => ({ current: null as any })); const loginShouldRejectRef = vi.hoisted(() => ({ value: false })); vi.mock('discord.js', () => { const Events = { MessageCreate: 'messageCreate', ClientReady: 'ready', Error: 'error', }; const GatewayIntentBits = { Guilds: 1, GuildMessages: 2, MessageContent: 4, DirectMessages: 8, }; class MockClient { eventHandlers = new Map(); user: any = { id: '999888777', tag: 'Andy#1234' }; private _ready = false; constructor(_opts: any) { clientRef.current = this; } on(event: string, handler: Handler) { const existing = this.eventHandlers.get(event) || []; existing.push(handler); this.eventHandlers.set(event, existing); return this; } once(event: string, handler: Handler) { return this.on(event, handler); } async login(_token: string) { if (loginShouldRejectRef.value) { throw new Error('An invalid token was provided.'); } this._ready = true; // Fire the ready event const readyHandlers = this.eventHandlers.get('ready') || []; for (const h of readyHandlers) { h({ user: this.user }); } } isReady() { return this._ready; } channels = { fetch: vi.fn().mockResolvedValue({ send: vi.fn().mockResolvedValue(undefined), sendTyping: vi.fn().mockResolvedValue(undefined), }), }; destroy() { this._ready = false; } } // Mock TextChannel type class TextChannel {} return { Client: MockClient, Events, GatewayIntentBits, MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 }, TextChannel, }; }); import { DiscordChannel, DiscordChannelOpts } from './discord.js'; import { logger } from '../logger.js'; // --- Test helpers --- const ONE_PIXEL_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64', ); function createTempPng(name = 'image.png'): { dir: string; filePath: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-')); const filePath = path.join(dir, name); fs.writeFileSync(filePath, ONE_PIXEL_PNG); return { dir, filePath }; } function createTestOpts( overrides?: Partial, ): DiscordChannelOpts { return { onMessage: vi.fn(), onChatMetadata: vi.fn(), roomBindings: vi.fn(() => ({ 'dc:1234567890123456': { name: 'Test Server #general', folder: 'test-server', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }, })), ...overrides, }; } function createMessage(overrides: { channelId?: string; content?: string; authorId?: string; authorUsername?: string; authorDisplayName?: string; memberDisplayName?: string; isBot?: boolean; guildName?: string; channelName?: string; messageId?: string; createdAt?: Date; attachments?: Map; reference?: { messageId?: string }; mentionsBotId?: boolean; }) { const channelId = overrides.channelId ?? '1234567890123456'; const authorId = overrides.authorId ?? '55512345'; const botId = '999888777'; // matches mock client user id const mentionsMap = new Map(); if (overrides.mentionsBotId) { mentionsMap.set(botId, { id: botId }); } return { channelId, id: overrides.messageId ?? 'msg_001', content: overrides.content ?? 'Hello everyone', createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'), author: { id: authorId, username: overrides.authorUsername ?? 'alice', displayName: overrides.authorDisplayName ?? 'Alice', bot: overrides.isBot ?? false, }, member: overrides.memberDisplayName ? { displayName: overrides.memberDisplayName } : null, guild: overrides.guildName ? { name: overrides.guildName } : null, channel: { name: overrides.channelName ?? 'general', messages: { fetch: vi.fn().mockResolvedValue({ author: { username: 'Bob', displayName: 'Bob' }, member: { displayName: 'Bob' }, }), }, }, mentions: { users: mentionsMap, }, attachments: overrides.attachments ?? new Map(), reference: overrides.reference ?? null, flags: { has: () => false }, }; } function currentClient() { return clientRef.current; } async function triggerMessage(message: any) { const handlers = currentClient().eventHandlers.get('messageCreate') || []; for (const h of handlers) await h(message); } // --- Tests --- beforeEach(() => { vi.clearAllMocks(); hasReviewerLeaseMock.mockReturnValue(false); loginShouldRejectRef.value = false; }); afterEach(() => { vi.restoreAllMocks(); }); describe('channel registration', () => { it('warns when the canonical owner token is not configured', () => { const ownerFactory = registeredChannelFactories.get('discord'); expect(ownerFactory).toBeTypeOf('function'); expect(ownerFactory?.(createTestOpts() as any)).toBeNull(); expect(logger.warn).toHaveBeenCalledWith( 'Discord: DISCORD_OWNER_BOT_TOKEN not set', ); }); }); // --- Connection lifecycle --- describe('connection lifecycle', () => { it('resolves connect() when client is ready', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); expect(channel.isConnected()).toBe(true); }); it('rejects connect() when login fails', async () => { loginShouldRejectRef.value = true; const opts = createTestOpts(); const channel = new DiscordChannel('bad-token', opts); await expect(channel.connect()).rejects.toThrow( 'An invalid token was provided.', ); expect(channel.isConnected()).toBe(false); }); it('registers message handlers on connect', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); expect(currentClient().eventHandlers.has('messageCreate')).toBe(true); expect(currentClient().eventHandlers.has('error')).toBe(true); expect(currentClient().eventHandlers.has('ready')).toBe(true); }); it('disconnects cleanly', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); expect(channel.isConnected()).toBe(true); await channel.disconnect(); expect(channel.isConnected()).toBe(false); }); it('isConnected() returns false before connect', () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); expect(channel.isConnected()).toBe(false); }); }); // --- Text message handling --- describe('text message handling', () => { it('delivers message for registered channel', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'Hello everyone', guildName: 'Test Server', channelName: 'general', }); await triggerMessage(msg); expect(opts.onChatMetadata).toHaveBeenCalledWith( 'dc:1234567890123456', expect.any(String), 'Test Server #general', 'discord', true, ); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ id: 'msg_001', chat_jid: 'dc:1234567890123456', sender: '55512345', sender_name: 'Alice', content: 'Hello everyone', is_from_me: false, }), ); }); it('only emits metadata for unregistered channels', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ channelId: '9999999999999999', content: 'Unknown channel', guildName: 'Other Server', }); await triggerMessage(msg); expect(opts.onChatMetadata).toHaveBeenCalledWith( 'dc:9999999999999999', expect.any(String), expect.any(String), 'discord', true, ); expect(opts.onMessage).not.toHaveBeenCalled(); }); it('ignores its own bot messages', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ authorId: '999888777', isBot: true, content: 'I am the connected bot', }); await triggerMessage(msg); expect(opts.onMessage).not.toHaveBeenCalled(); }); it('ignores other bot messages in normal rooms', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ authorId: '111222333', isBot: true, content: 'I am another bot', }); await triggerMessage(msg); expect(opts.onMessage).not.toHaveBeenCalled(); }); it('delivers other bot messages in paired rooms', async () => { hasReviewerLeaseMock.mockReturnValue(true); const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ authorId: '111222333', isBot: true, content: 'I am another bot', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: 'I am another bot', is_bot_message: true, }), ); }); it('uses member displayName when available (server nickname)', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'Hi', memberDisplayName: 'Alice Nickname', authorDisplayName: 'Alice Global', guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ sender_name: 'Alice Nickname' }), ); }); it('falls back to author displayName when no member', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'Hi', memberDisplayName: undefined, authorDisplayName: 'Alice Global', guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ sender_name: 'Alice Global' }), ); }); it('uses sender name for DM chats (no guild)', async () => { const opts = createTestOpts({ roomBindings: vi.fn(() => ({ 'dc:1234567890123456': { name: 'DM', folder: 'dm', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }, })), }); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'Hello', guildName: undefined, authorDisplayName: 'Alice', }); await triggerMessage(msg); expect(opts.onChatMetadata).toHaveBeenCalledWith( 'dc:1234567890123456', expect.any(String), 'Alice', 'discord', false, ); }); it('uses guild name + channel name for server messages', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'Hello', guildName: 'My Server', channelName: 'bot-chat', }); await triggerMessage(msg); expect(opts.onChatMetadata).toHaveBeenCalledWith( 'dc:1234567890123456', expect.any(String), 'My Server #bot-chat', 'discord', true, ); }); }); // --- bot mention handling --- describe('bot mention handling', () => { it('passes through <@botId> mentions without rewriting them', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: '<@999888777> what time is it?', mentionsBotId: true, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: '<@999888777> what time is it?', }), ); }); it('leaves mixed text and mentions untouched', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: '@Andy hello <@999888777>', mentionsBotId: true, guildName: 'Server', }); await triggerMessage(msg); // Should NOT prepend @Andy — already starts with trigger // But the <@botId> should still be stripped expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: '@Andy hello <@999888777>', }), ); }); it('does not translate when bot is not mentioned', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'hello everyone', guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: 'hello everyone', }), ); }); it('passes through <@!botId> nickname mentions without rewriting them', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: '<@!999888777> check this', mentionsBotId: true, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: '<@!999888777> check this', }), ); }); }); // --- Attachments --- describe('attachments', () => { let originalFetch: typeof globalThis.fetch; beforeEach(() => { originalFetch = globalThis.fetch; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve( new TextEncoder().encode('Hello from text file').buffer, ), text: () => Promise.resolve('Hello from text file'), }), ); }); afterEach(() => { globalThis.fetch = originalFetch; }); it('stores image attachment with placeholder', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'photo.png', contentType: 'image/png', url: 'https://cdn.example.com/photo.png', }, ], ]); const msg = createMessage({ content: '', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching(/^\[Image: .+\.png\]$/), }), ); }); it('stores video attachment with local path', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'clip.mp4', contentType: 'video/mp4', url: 'https://cdn.example.com/clip.mp4', }, ], ]); const msg = createMessage({ content: '', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching(/^\[Video: clip\.mp4 → .+\.mp4\]$/), }), ); }); it('stores file attachment with local path', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'report.pdf', contentType: 'application/pdf', url: 'https://cdn.example.com/report.pdf', }, ], ]); const msg = createMessage({ content: '', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching(/^\[File: report\.pdf → .+\.pdf\]$/), }), ); }); it('stores zip attachment with local path', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'display_latency_atopile_rev09.zip', contentType: 'application/zip', url: 'https://cdn.example.com/display_latency_atopile_rev09.zip', }, ], ]); const msg = createMessage({ content: '', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching( /^\[File: display_latency_atopile_rev09\.zip → .+\.zip\]$/, ), }), ); }); it('skips oversized file attachments before download', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'huge.zip', contentType: 'application/zip', size: 51 * 1024 * 1024, url: 'https://cdn.example.com/huge.zip', }, ], ]); const msg = createMessage({ content: '', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(globalThis.fetch).not.toHaveBeenCalled(); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: '[File: huge.zip (too large to download: 51.0 MiB > 50.0 MiB)]', }), ); }); it('includes text content with attachments', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'photo.jpg', contentType: 'image/jpeg', url: 'https://cdn.example.com/photo.jpg', }, ], ]); const msg = createMessage({ content: 'Check this out', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/), }), ); }); it('handles multiple attachments', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ [ 'att1', { id: 'att1', name: 'a.png', contentType: 'image/png', url: 'https://cdn.example.com/a.png', }, ], [ 'att2', { id: 'att2', name: 'b.txt', contentType: 'text/plain', url: 'https://cdn.example.com/b.txt', }, ], ]); const msg = createMessage({ content: '', attachments, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: expect.stringMatching( /^\[Image: .+\.png\]\n\[File: b\.txt → .+\.txt\]\nHello from text file$/, ), }), ); }); }); // --- Reply context --- describe('reply context', () => { it('includes reply author in content', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const msg = createMessage({ content: 'I agree with that', reference: { messageId: 'original_msg_id' }, guildName: 'Server', }); await triggerMessage(msg); expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ content: '[Reply to Bob] I agree with that', }), ); }); }); // --- sendMessage --- describe('sendMessage', () => { it('sends message via channel', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); await channel.sendMessage('dc:1234567890123456', 'Hello'); await currentClient().channels.fetch('1234567890123456'); expect(currentClient().channels.fetch).toHaveBeenCalledWith( '1234567890123456', ); }); it('strips dc: prefix from JID', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); await channel.sendMessage('dc:9876543210', 'Test'); expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210'); }); it('propagates send failure to the caller', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); currentClient().channels.fetch.mockRejectedValueOnce( new Error('Channel not found'), ); await expect( channel.sendMessage('dc:1234567890123456', 'Will fail'), ).rejects.toThrow('Channel not found'); }); it('rejects when client is not initialized', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); // Don't connect — client is null await expect( channel.sendMessage('dc:1234567890123456', 'No client'), ).rejects.toThrow('Discord client not initialized'); }); it('splits messages exceeding 2000 characters', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const mockChannel = { send: vi.fn().mockResolvedValue(undefined), sendTyping: vi.fn(), }; currentClient().channels.fetch.mockResolvedValue(mockChannel); const longText = 'x'.repeat(3000); await channel.sendMessage('dc:1234567890123456', longText); expect(mockChannel.send).toHaveBeenCalledTimes(2); expect(mockChannel.send).toHaveBeenNthCalledWith(1, { content: 'x'.repeat(2000), files: undefined, flags: 1 << 2, }); expect(mockChannel.send).toHaveBeenNthCalledWith(2, { content: 'x'.repeat(1000), files: undefined, flags: 1 << 2, }); }); it('sends structured attachments as Discord files', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const { dir, filePath } = createTempPng('structured.png'); const mockChannel = { send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), sendTyping: vi.fn(), }; currentClient().channels.fetch.mockResolvedValue(mockChannel); await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', { attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }], }); expect(mockChannel.send).toHaveBeenCalledWith({ content: '이미지를 생성했습니다.', files: [{ attachment: filePath, name: 'result.png' }], flags: 1 << 2, }); fs.rmSync(dir, { recursive: true, force: true }); }); it('uses legacy image tags as Discord attachment fallback', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const { dir, filePath } = createTempPng('screenshot.png'); const mockChannel = { send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), sendTyping: vi.fn(), }; currentClient().channels.fetch.mockResolvedValue(mockChannel); await channel.sendMessage( 'dc:1234567890123456', `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`, ); expect(mockChannel.send).toHaveBeenCalledWith({ content: '스크린샷입니다.', files: [{ attachment: filePath, name: 'screenshot.png' }], flags: 1 << 2, }); fs.rmSync(dir, { recursive: true, force: true }); }); it('logs channel name and Discord message ids after sending', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const mockChannel = { send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), sendTyping: vi.fn(), }; currentClient().channels.fetch.mockResolvedValue(mockChannel); await channel.sendMessage('dc:1234567890123456', 'Hello'); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ jid: 'dc:1234567890123456', channelName: 'discord', deliveryMode: 'send', chunkCount: 1, messageId: 'discord-message-1', messageIds: ['discord-message-1'], }), 'Discord message sent', ); }); }); // --- ownsJid --- describe('ownsJid', () => { it('owns dc: JIDs', () => { const channel = new DiscordChannel('test-token', createTestOpts()); expect(channel.ownsJid('dc:1234567890123456')).toBe(true); }); it('can be configured as an outbound-only role bot', () => { const channel = new DiscordChannel( 'test-token', createTestOpts(), undefined, 'discord-review', false, false, ); expect(channel.ownsJid('dc:1234567890123456')).toBe(false); }); it('does not own WhatsApp group JIDs', () => { const channel = new DiscordChannel('test-token', createTestOpts()); expect(channel.ownsJid('12345@g.us')).toBe(false); }); it('does not own Telegram JIDs', () => { const channel = new DiscordChannel('test-token', createTestOpts()); expect(channel.ownsJid('tg:123456789')).toBe(false); }); it('does not own unknown JID formats', () => { const channel = new DiscordChannel('test-token', createTestOpts()); expect(channel.ownsJid('random-string')).toBe(false); }); }); // --- setTyping --- describe('setTyping', () => { it('sends typing indicator when isTyping is true', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const mockChannel = { send: vi.fn(), sendTyping: vi.fn().mockResolvedValue(undefined), }; currentClient().channels.fetch.mockResolvedValue(mockChannel); await channel.setTyping('dc:1234567890123456', true); expect(mockChannel.sendTyping).toHaveBeenCalled(); }); it('does nothing when isTyping is false', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); await channel.setTyping('dc:1234567890123456', false); // channels.fetch should NOT be called expect(currentClient().channels.fetch).not.toHaveBeenCalled(); }); it('does not send stale typing after typing was disabled mid-flight', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); let resolveFetch!: (value: unknown) => void; const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; }); const mockChannel = { send: vi.fn(), sendTyping: vi.fn().mockResolvedValue(undefined), }; currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise); const typingPromise = channel.setTyping('dc:1234567890123456', true); await Promise.resolve(); await channel.setTyping('dc:1234567890123456', false); resolveFetch(mockChannel); await typingPromise; expect(mockChannel.sendTyping).not.toHaveBeenCalled(); }); it('does nothing when client is not initialized', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); // Don't connect await channel.setTyping('dc:1234567890123456', true); // No error }); }); // --- Channel properties --- describe('channel properties', () => { it('has name "discord"', () => { const channel = new DiscordChannel('test-token', createTestOpts()); expect(channel.name).toBe('discord'); }); });