364 lines
13 KiB
TypeScript
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 }]);
|
|
});
|
|
});
|