import { beforeEach, describe, expect, it } from 'vitest'; import { ObjectId } from 'mongodb'; import type { Repos } from '../src/db.js'; import type { Config } from '../src/config.js'; import { buildTelegramMessages } from '../src/telegram/message.js'; import { computeWaitMs, PerChatThrottle } from '../src/telegram/rate.js'; import { interpretResponse, type TelegramClient, type TgResult } from '../src/telegram/client.js'; import { processTelegramJob, type Deps } from '../src/handlers.js'; import type { NotificationDoc } from '../src/types.js'; import { aNotif, aUser, freshRepos, silentLog, testCfg } from './helpers.js'; describe('buildTelegramMessages', () => { it('builds text (content + firelink) and location', () => { const notif = aNotif({ type: 'telegram', content: '🔥 Alerta de posible fuego a 5 км de distancia (fuente NASA)', sealed: 'ENC', geo: { type: 'Point', coordinates: [-3.7, 40.4] } }); const m = buildTelegramMessages(notif, 'es', 'fuegos.comunes.org'); expect(m.text).toBe('🔥 Alerta de posible fuego a 5 км de distancia (fuente NASA)\n[más información](https://fuegos.comunes.org/fire/ENC)'); expect(m.parseMode).toBe('Markdown'); expect(m.latitude).toBe(40.4); expect(m.longitude).toBe(-3.7); }); it('uses the english firelink', () => { const notif = aNotif({ type: 'telegram', sealed: 'X' }); expect(buildTelegramMessages(notif, 'en', 'fires.comunes.org').text).toContain('[more information](https://fires.comunes.org/fire/X)'); }); }); describe('rate limiting', () => { it('computeWaitMs respects the per-chat interval', () => { expect(computeWaitMs(undefined, 1000, 1000)).toBe(0); expect(computeWaitMs(1000, 1500, 1000)).toBe(500); expect(computeWaitMs(1000, 2000, 1000)).toBe(0); }); it('PerChatThrottle waits then records', async () => { let t = 0; const waits: number[] = []; const thr = new PerChatThrottle(1000, () => t, async (ms) => { waits.push(ms); t += ms; }); await thr.acquire(42); // first: no wait await thr.acquire(42); // immediately after: waits 1000 expect(waits).toEqual([1000]); }); }); describe('interpretResponse', () => { it('ok', () => { expect(interpretResponse(200, { ok: true })).toEqual({ ok: true }); }); it('429 with retry_after', () => { const r = interpretResponse(429, { ok: false, error_code: 429, description: 'Too Many Requests', parameters: { retry_after: 7 } }); expect(r).toEqual({ ok: false, retryAfterMs: 7000, error: 'Too Many Requests' }); }); it('403 marks blocked', () => { const r = interpretResponse(403, { ok: false, error_code: 403, description: 'bot was blocked by the user' }); expect(r).toMatchObject({ ok: false, blocked: true }); }); }); // ---- handler ---- function fakeTgClient(result: TgResult = { ok: true }) { const calls: Array<{ method: string; chatId: number }> = []; const client: TelegramClient = { async sendMessage(chatId) { calls.push({ method: 'message', chatId }); return result; }, async sendLocation(chatId) { calls.push({ method: 'location', chatId }); return result; }, }; return { client, calls }; } let repos: Repos; const baseCfg: Config = testCfg({ ownedChannels: ['telegram'] }); beforeEach(async () => { repos = await freshRepos(); }); function tgDeps(clientResult: TgResult, over: Partial = {}): { deps: Deps; calls: Array<{ method: string; chatId: number }> } { const { client, calls } = fakeTgClient(clientResult); const deps: Deps = { cfg: baseCfg, repos, log: silentLog, telegram: { clientFor: () => client, throttle: new PerChatThrottle(0, () => 0, async () => {}), server: 'fuegos.comunes.org', }, ...over, }; return { deps, calls }; } async function seedTelegramNotif(chatId = 111): Promise { const subsId = new ObjectId(); await repos.subscriptions.insertOne({ _id: subsId, location: { lat: 40.4, lon: -3.7 }, geo: { type: 'Point', coordinates: [-3.7, 40.4] }, distance: 40, owner: 'u1', type: 'telegram', chatId } as never); await repos.users.insertOne(aUser({ _id: 'u1', lang: 'es' })); const notif = aNotif({ type: 'telegram', userId: 'u1', subsId }); await repos.notifications.insertOne(notif); return notif; } describe('processTelegramJob', () => { it('sends text + location and marks telegramNotified (full)', async () => { const notif = await seedTelegramNotif(111); const { deps, calls } = tgDeps({ ok: true }); await processTelegramJob(deps, notif._id.toHexString()); expect(calls).toEqual([{ method: 'message', chatId: 111 }, { method: 'location', chatId: 111 }]); const after = await repos.notifications.findOne({ _id: notif._id }); expect(after?.telegramNotified).toBe(true); expect(await repos.sends.findOne({ notificationId: notif._id, channel: 'telegram' })).not.toBeNull(); }); it('dry-run does not send', async () => { const notif = await seedTelegramNotif(); const { deps, calls } = tgDeps({ ok: true }, { cfg: { ...baseCfg, mode: 'dry-run' } }); await processTelegramJob(deps, notif._id.toHexString()); expect(calls).toHaveLength(0); expect((await repos.notifications.findOne({ _id: notif._id }))?.telegramNotified).toBeUndefined(); }); it('is idempotent: does not resend', async () => { const notif = await seedTelegramNotif(); const { deps, calls } = tgDeps({ ok: true }); await processTelegramJob(deps, notif._id.toHexString()); await processTelegramJob(deps, notif._id.toHexString()); // guard: already notified expect(calls).toHaveLength(2); // only the first run sent (2 msgs) }); it('marks the sub inactive when the bot is blocked (403), no retry', async () => { const notif = await seedTelegramNotif(222); const { deps } = tgDeps({ ok: false, blocked: true, error: 'blocked' }); await processTelegramJob(deps, notif._id.toHexString()); const sub = await repos.subscriptions.findOne({ chatId: 222 }); expect((sub as { active?: boolean }).active).toBe(false); const send = await repos.sends.findOne({ notificationId: notif._id, channel: 'telegram' }); expect(send?.status).toBe('dead-token'); }); it('releases reservation and rethrows on 429 (retry)', async () => { const notif = await seedTelegramNotif(); const { deps } = tgDeps({ ok: false, retryAfterMs: 5000, error: 'Too Many Requests' }); await expect(processTelegramJob(deps, notif._id.toHexString())).rejects.toThrow(); expect(await repos.sends.countDocuments({ notificationId: notif._id })).toBe(0); }); });