import { ConfigService } from '@nestjs/config'; import { TgContactsService } from '../src/modules/tg-contacts/tg-contacts.service'; import { TgMessagesService } from '../src/modules/tg-messages/tg-messages.service'; import { TgChatsService } from '../src/modules/tg-chats/tg-chats.service'; import { TgWebhooksService } from '../src/modules/tg-webhooks/tg-webhooks.service'; import { TgRateLimitService } from '../src/modules/tg-rate-limit/tg-rate-limit.service'; import { WebhookDeliveryProcessor } from '../src/modules/tg-webhooks/webhook-delivery.processor'; import { DeliveryStatus } from '../src/database/entities/tg-webhook-delivery.entity'; import { parseFloodWaitSeconds, TdlibClientManagerService, } from '../src/modules/tdlib/tdlib-client-manager.service'; describe('Business service behavior', () => { const accounts = { findById: jest.fn().mockResolvedValue({ id: 'account-1' }) }; const tdlib = { sendRequest: jest.fn().mockResolvedValue({ ok: true }) }; beforeEach(() => jest.clearAllMocks()); describe('Telegram FloodWait parsing', () => { it('extracts retry_after seconds from TDLib errors', () => { expect(parseFloodWaitSeconds({ details: { tdlibCode: 429, tdlibMessage: 'Too Many Requests: retry after 17', }, })).toBe(17); expect(parseFloodWaitSeconds({ details: { tdlibCode: 420, tdlibMessage: 'FLOOD_WAIT_9' }, })).toBe(9); }); it('does not retry unrelated TDLib errors', () => { expect(parseFloodWaitSeconds({ details: { tdlibCode: 400, tdlibMessage: 'Chat not found' }, })).toBeNull(); }); it('waits and retries a TDLib request after FloodWait', async () => { const config = { get: jest.fn((key: string) => ({ TG_API_ID: '1', TG_API_HASH: 'test', TDLIB_FLOOD_WAIT_RETRIES: '2', TDLIB_FLOOD_WAIT_MAX_SECONDS: '5', })[key] || ''), } as unknown as ConfigService; const manager = new TdlibClientManagerService(config); let attempts = 0; const worker = { postMessage: jest.fn((message: any) => { attempts += 1; const extra = message.request['@extra']; setImmediate(() => (manager as any).handleTdlibResponse({ clientId: 1, data: attempts === 1 ? { '@type': 'error', code: 429, message: 'Too Many Requests: retry after 1', '@extra': extra, } : { '@type': 'ok', '@extra': extra }, })); }), }; (manager as any).workers.set('account-1', worker); await expect(manager.sendRequest('account-1', { '@type': 'getMe', })).resolves.toMatchObject({ '@type': 'ok' }); expect(attempts).toBe(2); }); }); describe('TgContactsService', () => { const contacts = new TgContactsService( { find: jest.fn() } as any, tdlib as any, accounts as any, ); it('searches a phone number with searchUserByPhoneNumber', async () => { await contacts.searchUser('tenant-1', 'account-1', '+86 138-0013-8000'); expect(tdlib.sendRequest).toHaveBeenCalledWith('account-1', expect.objectContaining({ '@type': 'searchUserByPhoneNumber', phone_number: '+8613800138000', })); }); it('searches a username with searchPublicChat', async () => { await contacts.searchUser('tenant-1', 'account-1', '@telegram'); expect(tdlib.sendRequest).toHaveBeenCalledWith('account-1', { '@type': 'searchPublicChat', username: 'telegram', }); }); it('uses server search for a general query and clamps the limit', async () => { await contacts.searchUser('tenant-1', 'account-1', 'John Smith', 500); expect(tdlib.sendRequest).toHaveBeenCalledWith('account-1', { '@type': 'searchChatsOnServer', query: 'John Smith', limit: 100, }); }); }); describe('TgMessagesService', () => { const sendJobs = { findOne: jest.fn().mockResolvedValue(null) }; const messages = new TgMessagesService( {} as any, sendJobs as any, { add: jest.fn() } as any, tdlib as any, accounts as any, ); it('scopes send-job lookups to the verified account', async () => { await messages.getSendJobStatus('tenant-1', 'account-1', 'job-1'); expect(accounts.findById).toHaveBeenCalledWith('tenant-1', 'account-1'); expect(sendJobs.findOne).toHaveBeenCalledWith({ where: [ { bullJobId: 'job-1', accountId: 'account-1' }, { id: 'job-1', accountId: 'account-1' }, ], }); }); it('maps pin operations to TDLib', async () => { await messages.pinMessage('tenant-1', 'account-1', { chatId: 10, messageId: 20, }); expect(tdlib.sendRequest).toHaveBeenCalledWith('account-1', expect.objectContaining({ '@type': 'pinChatMessage', chat_id: 10, message_id: 20, })); }); }); describe('TgChatsService', () => { const chats = new TgChatsService( {} as any, tdlib as any, accounts as any, ); it('maps chat pin operations to TDLib', async () => { await chats.setPinned('tenant-1', 'account-1', 123, true); expect(tdlib.sendRequest).toHaveBeenLastCalledWith('account-1', { '@type': 'toggleChatIsPinned', chat_list: { '@type': 'chatListMain' }, chat_id: 123, is_pinned: true, }); }); it('persists Telegram broadcast channels as channel instead of supergroup', async () => { const repo = { findOne: jest.fn().mockResolvedValue(null), create: jest.fn((value) => value), save: jest.fn(async (value) => value), }; const service = new TgChatsService(repo as any, tdlib as any, accounts as any); const saved = await (service as any).persistTdlibChat('account-1', { id: -100123, title: 'Broadcast', type: { '@type': 'chatTypeSupergroup', supergroup_id: 123, is_channel: true }, }); expect(saved.type).toBe('channel'); }); }); describe('TgWebhooksService', () => { const webhookRepo = { findOne: jest.fn(), }; const deliveryRepo = { find: jest.fn().mockResolvedValue([]), }; const webhooks = new TgWebhooksService( webhookRepo as any, deliveryRepo as any, {} as any, accounts as any, ); it('verifies tenant/account ownership before returning deliveries', async () => { webhookRepo.findOne.mockResolvedValue({ id: 'hook-1', accountId: 'account-1' }); await webhooks.getDeliveries('tenant-1', 'account-1', 'hook-1'); expect(accounts.findById).toHaveBeenCalledWith('tenant-1', 'account-1'); expect(webhookRepo.findOne).toHaveBeenCalledWith({ where: { id: 'hook-1', accountId: 'account-1' }, }); expect(deliveryRepo.find).toHaveBeenCalledWith(expect.objectContaining({ where: { subscriptionId: 'hook-1' }, })); }); }); describe('TgRateLimitService', () => { it('throws once an action exceeds its configured limit', async () => { const repo = { create: jest.fn((value) => value), save: jest.fn().mockResolvedValue({}), }; const config = { get: jest.fn((key: string) => key.endsWith('Ttl') ? 60000 : 1), } as unknown as ConfigService; const service = new TgRateLimitService(repo as any, config); await expect(service.checkRateLimit('account-1', 'send')).resolves.toBe(true); await expect(service.checkRateLimit('account-1', 'send')).rejects.toMatchObject({ status: 429, }); }); }); describe('WebhookDeliveryProcessor', () => { it('performs a signed HTTP delivery and persists success', async () => { const delivery = { id: 'delivery-1', status: DeliveryStatus.PENDING, attempt: 0, }; const repo = { findOne: jest.fn().mockResolvedValue(delivery), save: jest.fn(async (value) => value), }; const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 204, text: async () => '', } as Response); const processor = new WebhookDeliveryProcessor(repo as any); await processor.deliver({ data: { deliveryId: 'delivery-1', url: 'https://example.com/hook', payload: { event: 'message.received' }, secret: 'test-secret', }, attemptsMade: 0, } as any); expect(fetchMock).toHaveBeenCalledWith( 'https://example.com/hook', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'X-Webhook-Signature': expect.stringMatching(/^sha256=/), }), }), ); expect(delivery.status).toBe(DeliveryStatus.SUCCESS); fetchMock.mockRestore(); }); }); });