Files
connectai/tests/telegramBot.test.ts
T

364 lines
13 KiB
TypeScript

/**
* 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<TelegramUpdate[] | Error> = [];
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<TelegramUser> { return this.meValue; }
async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
this.getUpdatesCalls.push(opts);
if (this._queue.length === 0) {
this._onDrain?.();
await new Promise<void>((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<TelegramMessage> {
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((r) => setTimeout(r, 20));
await bot.stop();
expect(handled).toEqual([{ text: 'real', chatId: 99 }]);
});
});