266 lines
8.9 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|