Files
lingxi-web/telegram-gateway/test/business-services.spec.ts
T
wmq 06112602ec feat(gateway): include production Telegram user gateway
Version the NestJS TDLib gateway with Docker deployment, migrations, automated tests and platform integration documentation while excluding all account sessions, credentials, runtime databases and backups.
2026-08-01 18:38:20 +08:00

264 lines
8.8 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 () => {
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();
});
});
});