Files

266 lines
8.9 KiB
TypeScript

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 () => {
let stored: any;
const repo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((value) => value),
upsert: jest.fn(async (value) => { stored = value; }),
findOneOrFail: jest.fn(async () => stored),
};
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();
});
});
});