/** * Unit tests for TelegramBot + truncateForTelegram. * * Strategy: * - TelegramBot is driven by an injected ITelegramClient stub. We script * `getUpdates` to return queued batches and assert that: * - the offset cursor advances correctly, * - replies fire through sendMessage, * - errors trigger backoff via the injected sleep, * - aborted shutdown exits cleanly without sending residual messages, * - invalid-token (401) stops the loop without burning retries. * - The `sleep` injection turns the polling backoff into a counter we can * drain synchronously inside the test loop. */ import { TelegramBot } from '../src/integrations/telegram/telegramBot'; import { TelegramClientError, truncateForTelegram, type ITelegramClient, type GetUpdatesOptions, type SendMessageOptions, } from '../src/integrations/telegram/telegramClient'; import type { TelegramMessage, TelegramUpdate, TelegramUser } from '../src/integrations/telegram/types'; class StubClient implements ITelegramClient { public sent: SendMessageOptions[] = []; public getUpdatesCalls: GetUpdatesOptions[] = []; private _queue: Array = []; private _onDrain?: () => void; private _waiters: Array<() => void> = []; public failSendOnce: Error | null = null; constructor(public meValue: TelegramUser = { id: 1, is_bot: true, first_name: 'TestBot' }) {} queueBatch(updates: TelegramUpdate[]) { this._queue.push(updates); this._wakeWaiters(); } queueError(err: Error) { this._queue.push(err); this._wakeWaiters(); } onceDrained(cb: () => void) { this._onDrain = cb; } private _wakeWaiters() { const w = this._waiters.splice(0); for (const fn of w) fn(); } async getMe(): Promise { return this.meValue; } async getUpdates(opts: GetUpdatesOptions): Promise { this.getUpdatesCalls.push(opts); if (this._queue.length === 0) { this._onDrain?.(); await new Promise((resolve, reject) => { const onAbort = () => reject(new TelegramClientError('aborted', 'aborted')); opts.signal?.addEventListener('abort', onAbort); this._waiters.push(() => { opts.signal?.removeEventListener('abort', onAbort); resolve(); }); }); if (this._queue.length === 0) return []; } const next = this._queue.shift()!; if (next instanceof Error) throw next; return next; } async sendMessage(opts: SendMessageOptions): Promise { if (this.failSendOnce) { const err = this.failSendOnce; this.failSendOnce = null; throw err; } this.sent.push(opts); return { message_id: 1, date: 0, chat: { id: opts.chatId, type: 'private' }, text: opts.text, }; } } function update(id: number, chatId: number, text: string): TelegramUpdate { return { update_id: id, message: { message_id: id, date: 0, chat: { id: chatId, type: 'private' }, text }, }; } const flush = () => new Promise((r) => setImmediate(r)); describe('truncateForTelegram', () => { test('passes through short text', () => { expect(truncateForTelegram('hello')).toBe('hello'); }); test('truncates over 4096 chars with ellipsis', () => { const long = 'x'.repeat(5000); const out = truncateForTelegram(long); expect(out.length).toBeLessThanOrEqual(4096); expect(out.endsWith('(truncated)')).toBe(true); }); test('handles non-string defensively', () => { expect(truncateForTelegram(undefined as any)).toBe(''); }); }); describe('TelegramBot', () => { let client: StubClient; let handled: Array<{ text: string; chatId: number }>; let bot: TelegramBot; beforeEach(() => { client = new StubClient(); handled = []; }); afterEach(async () => { if (bot) await bot.stop(); }); test('start is idempotent', () => { bot = new TelegramBot({ client, handle: async () => null }); bot.start(); expect(bot.isRunning()).toBe(true); bot.start(); expect(bot.isRunning()).toBe(true); }); test('processes updates and advances offset', async () => { bot = new TelegramBot({ client, handle: async (text, chatId) => { handled.push({ text, chatId }); return `echo: ${text}`; }, sleep: () => Promise.resolve(), }); client.queueBatch([update(101, 999, 'hi'), update(102, 999, 'second')]); client.onceDrained(() => { /* now hanging, safe to stop */ void bot.stop(); }); bot.start(); await new Promise((r) => setTimeout(r, 30)); await bot.stop(); expect(handled).toEqual([ { text: 'hi', chatId: 999 }, { text: 'second', chatId: 999 }, ]); expect(client.sent.map(s => s.text)).toEqual(['echo: hi', 'echo: second']); // Second poll uses offset = 103 (lastId + 1) const offsets = client.getUpdatesCalls.map(c => c.offset); expect(offsets[1]).toBe(103); }); test('skips reply when handler returns null', async () => { bot = new TelegramBot({ client, handle: async () => null, sleep: () => Promise.resolve(), }); client.queueBatch([update(1, 1, 'ignored')]); client.onceDrained(() => void bot.stop()); bot.start(); await new Promise((r) => setTimeout(r, 20)); await bot.stop(); expect(client.sent).toEqual([]); }); test('handler exception is converted to a reply (loop survives)', async () => { bot = new TelegramBot({ client, handle: async () => { throw new Error('boom'); }, sleep: () => Promise.resolve(), }); client.queueBatch([update(1, 1, 'hi')]); client.onceDrained(() => void bot.stop()); bot.start(); await new Promise((r) => setTimeout(r, 20)); await bot.stop(); expect(client.sent).toHaveLength(1); expect(client.sent[0].text).toContain('boom'); }); test('network error triggers backoff and retries', async () => { const sleeps: number[] = []; bot = new TelegramBot({ client, handle: async () => null, initialBackoffMs: 100, maxBackoffMs: 800, sleep: async (ms) => { sleeps.push(ms); }, }); client.queueError(new TelegramClientError('network', 'temp net')); client.queueError(new TelegramClientError('network', 'temp net')); client.queueBatch([update(1, 1, 'after-recovery')]); client.onceDrained(() => void bot.stop()); bot.start(); await new Promise((r) => setTimeout(r, 30)); await bot.stop(); // First two failures should produce 100ms then 200ms (exponential, capped at 800). expect(sleeps.length).toBeGreaterThanOrEqual(2); expect(sleeps[0]).toBe(100); expect(sleeps[1]).toBe(200); // Loop continued past errors and eventually saw the success batch. expect(client.getUpdatesCalls.length).toBeGreaterThanOrEqual(3); }); test('401 invalid-token stops loop without retries', async () => { const sleeps: number[] = []; bot = new TelegramBot({ client, handle: async () => null, sleep: async (ms) => { sleeps.push(ms); }, }); client.queueError(new TelegramClientError('api', 'unauthorized', 401)); bot.start(); await new Promise((r) => setTimeout(r, 20)); // Bot should have stopped itself. expect(bot.isRunning()).toBe(false); expect(sleeps).toEqual([]); }); test('aborted exits cleanly without backoff', async () => { const sleeps: number[] = []; bot = new TelegramBot({ client, handle: async () => null, sleep: async (ms) => { sleeps.push(ms); }, }); client.onceDrained(() => void bot.stop()); bot.start(); await new Promise((r) => setTimeout(r, 20)); await bot.stop(); expect(sleeps).toEqual([]); // no backoff on graceful abort }); test('reply send failure is logged but loop continues', async () => { bot = new TelegramBot({ client, handle: async () => 'reply', sleep: () => Promise.resolve(), }); client.failSendOnce = new Error('send failed'); client.queueBatch([update(1, 1, 'first'), update(2, 1, 'second')]); client.onceDrained(() => void bot.stop()); bot.start(); await new Promise((r) => setTimeout(r, 30)); await bot.stop(); // First send failed, second still attempted. expect(client.sent).toHaveLength(1); expect(client.sent[0].text).toBe('reply'); }); test('enrollNextChat captures the next message and skips the AI handler', async () => { bot = new TelegramBot({ client, handle: async (text, chatId) => { handled.push({ text, chatId }); return 'should-not-fire'; }, sleep: () => Promise.resolve(), }); client.queueBatch([ { update_id: 50, message: { message_id: 1, date: 0, chat: { id: 777, type: 'private' }, from: { id: 777, is_bot: false, first_name: 'Alice', username: 'alice' }, text: 'hello', }, }, ]); client.onceDrained(() => void bot.stop()); bot.start(); const captured = await bot.enrollNextChat(5000); await bot.stop(); expect(captured.chatId).toBe(777); expect(captured.username).toBe('alice'); expect(captured.firstName).toBe('Alice'); // Normal handler must NOT have fired for the captured update. expect(handled).toEqual([]); // Bot should have sent the enrollment ack (not a real reply). expect(client.sent).toHaveLength(1); expect(client.sent[0].text).toContain('등록'); }); test('enrollNextChat times out cleanly when no message arrives', async () => { bot = new TelegramBot({ client, handle: async () => null, sleep: () => Promise.resolve(), }); bot.start(); await expect(bot.enrollNextChat(50)).rejects.toThrow(/within|received/i); await bot.stop(); }); test('a second enrollNextChat supersedes the first', async () => { bot = new TelegramBot({ client, handle: async () => null, sleep: () => Promise.resolve(), }); bot.start(); const first = bot.enrollNextChat(60_000); // Eat the unhandled rejection by attaching a handler immediately. const firstFailed = first.catch((e) => e.message); const secondP = bot.enrollNextChat(60_000); client.queueBatch([ { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 5, type: 'private' }, text: 'hi' } }, ]); client.onceDrained(() => void bot.stop()); const second = await secondP; await bot.stop(); expect(second.chatId).toBe(5); await expect(firstFailed).resolves.toMatch(/superseded/i); }); test('cancelEnrollment rejects the pending promise', async () => { bot = new TelegramBot({ client, handle: async () => null, sleep: () => Promise.resolve(), }); bot.start(); const p = bot.enrollNextChat(60_000); bot.cancelEnrollment(); await expect(p).rejects.toThrow(/cancel/i); await bot.stop(); }); test('updates without text or chatId are ignored', async () => { bot = new TelegramBot({ client, handle: async (text, chatId) => { handled.push({ text, chatId }); return null; }, sleep: () => Promise.resolve(), }); const malformed: TelegramUpdate = { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 0, type: 'private' } } }; const noChat: TelegramUpdate = { update_id: 2, message: { message_id: 2, date: 0, chat: undefined as any, text: 'hi' } }; const valid = update(3, 99, 'real'); client.queueBatch([malformed, noChat, valid]); client.onceDrained(() => void bot.stop()); bot.start(); await new Promise((r) => setTimeout(r, 20)); await bot.stop(); expect(handled).toEqual([{ text: 'real', chatId: 99 }]); }); });