312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { VkClient, fetchPaginatedVk } from '../src/integrations/vk/vk-client';
|
|
import { createServiceAuth, createUserAuth, createCommunityAuth, redactToken } from '../src/integrations/vk/vk-auth';
|
|
import { VkProvider } from '../src/providers/vk/vk-provider';
|
|
import {
|
|
VkAuthError,
|
|
VkPermissionError,
|
|
VkRateLimitError,
|
|
VkTemporaryError,
|
|
VkValidationError,
|
|
VkTimeoutError
|
|
} from '../src/integrations/vk/vk-errors';
|
|
import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit';
|
|
|
|
class NoopRateLimiter implements IVkRateLimiter {
|
|
async acquire(): Promise<void> {}
|
|
reset(): void {}
|
|
}
|
|
|
|
describe('VK Client & Provider Mocked HTTP Integration', () => {
|
|
const serviceToken = 'vk1.a.secret_service_token_123456789';
|
|
let client: VkClient;
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
client = new VkClient({
|
|
rateLimiter: new NoopRateLimiter(),
|
|
defaultTimeoutMs: 1000,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function mockFetchSuccess<T>(data: T) {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
text: async () => JSON.stringify({ response: data }),
|
|
json: async () => ({ response: data }),
|
|
});
|
|
}
|
|
|
|
function mockFetchVkError(errorCode: number, errorMsg: string) {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
text: async () => JSON.stringify({ error: { error_code: errorCode, error_msg: errorMsg } }),
|
|
json: async () => ({ error: { error_code: errorCode, error_msg: errorMsg } }),
|
|
});
|
|
}
|
|
|
|
it('successfully calls VK API and parses response payload', async () => {
|
|
mockFetchSuccess({ items: [{ id: 100, owner_id: -1, text: 'Hello VK post' }] });
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
const result = await client.call<{ items: Array<{ id: number; text: string }> }>(
|
|
'wall.getById',
|
|
{ posts: '-1_100' },
|
|
auth
|
|
);
|
|
|
|
expect(result.items).toBeDefined();
|
|
expect(result.items[0].id).toBe(100);
|
|
expect(result.items[0].text).toBe('Hello VK post');
|
|
});
|
|
|
|
it('supports User and Community auth context types', async () => {
|
|
mockFetchSuccess({ count: 1, items: [100] });
|
|
|
|
const userAuth = createUserAuth('vk1.a.user_token');
|
|
const commAuth = createCommunityAuth('vk1.a.comm_token', '123456');
|
|
|
|
const resUser = await client.call('likes.getList', { type: 'post' }, userAuth);
|
|
expect(resUser).toBeDefined();
|
|
|
|
const resComm = await client.call('likes.getList', { type: 'post' }, commAuth);
|
|
expect(resComm).toBeDefined();
|
|
});
|
|
|
|
it('throws VkTimeoutError when request exceeds configured timeout', async () => {
|
|
const slowClient = new VkClient({
|
|
rateLimiter: new NoopRateLimiter(),
|
|
defaultTimeoutMs: 20,
|
|
});
|
|
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
|
new Promise(resolve => setTimeout(resolve, 100))
|
|
);
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
await expect(
|
|
slowClient.call('wall.getById', { posts: '-1_1' }, auth, { maxRetries: 0 })
|
|
).rejects.toThrow(VkTimeoutError);
|
|
});
|
|
|
|
it('aborts immediately when caller AbortSignal fires', async () => {
|
|
const controller = new AbortController();
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
|
new Promise((_, reject) => {
|
|
controller.signal.addEventListener('abort', () => reject(new Error('Caller aborted')));
|
|
})
|
|
);
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
const promise = client.call('wall.getById', {}, auth, { signal: controller.signal });
|
|
controller.abort();
|
|
|
|
await expect(promise).rejects.toThrow();
|
|
});
|
|
|
|
it('retries on retryable server error (error_code 10) up to maxRetries', async () => {
|
|
let callCount = 0;
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
callCount++;
|
|
if (callCount < 3) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({ error: { error_code: 10, error_msg: 'Internal server error' } }),
|
|
json: async () => ({ error: { error_code: 10, error_msg: 'Internal server error' } }),
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({ response: { success: 1 } }),
|
|
json: async () => ({ response: { success: 1 } }),
|
|
};
|
|
});
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
const res = await client.call<{ success: number }>(
|
|
'wall.getById',
|
|
{},
|
|
auth,
|
|
{ maxRetries: 3, retryInitialDelayMs: 5 }
|
|
);
|
|
|
|
expect(res.success).toBe(1);
|
|
expect(callCount).toBe(3);
|
|
});
|
|
|
|
it('does NOT retry on non-retryable auth error (error_code 5)', async () => {
|
|
let callCount = 0;
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
callCount++;
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({ error: { error_code: 5, error_msg: 'User auth failed' } }),
|
|
json: async () => ({ error: { error_code: 5, error_msg: 'User auth failed' } }),
|
|
};
|
|
});
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
await expect(
|
|
client.call('wall.getById', {}, auth, { maxRetries: 3 })
|
|
).rejects.toThrow(VkAuthError);
|
|
|
|
expect(callCount).toBe(1);
|
|
});
|
|
|
|
it('does NOT retry on permission error (error_code 7)', async () => {
|
|
mockFetchVkError(7, 'Permission to perform this action is denied');
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
await expect(
|
|
client.call('wall.getById', {}, auth, { maxRetries: 2 })
|
|
).rejects.toThrow(VkPermissionError);
|
|
});
|
|
|
|
it('throws VkValidationError on malformed non-JSON response', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => '<html>502 Bad Gateway</html>',
|
|
json: async () => { throw new Error('invalid json'); },
|
|
});
|
|
|
|
const auth = createServiceAuth(serviceToken);
|
|
await expect(
|
|
client.call('wall.getById', {}, auth, { maxRetries: 0 })
|
|
).rejects.toThrow(VkValidationError);
|
|
});
|
|
|
|
it('pagination abstraction handles single and multiple pages seamlessly', async () => {
|
|
let page = 0;
|
|
const fetchPage = vi.fn(async (offset: number, count: number) => {
|
|
page++;
|
|
if (page === 1) {
|
|
return { items: [1, 2, 3], totalCount: 6 };
|
|
}
|
|
if (page === 2) {
|
|
return { items: [4, 5, 6], totalCount: 6 };
|
|
}
|
|
return { items: [], totalCount: 6 };
|
|
});
|
|
|
|
const items = await fetchPaginatedVk<number>({
|
|
pageSize: 3,
|
|
fetchPage,
|
|
});
|
|
|
|
expect(items).toEqual([1, 2, 3, 4, 5, 6]);
|
|
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('VkProvider deduplicates LIKE + COMMENT into a single merged participant', async () => {
|
|
// 1. Likes response: user 100
|
|
// 2. Comments response: user 100 and user 200
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
|
|
if (url.includes('likes.getList')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({
|
|
response: {
|
|
count: 1,
|
|
items: [{ id: 100, first_name: 'Alice', last_name: 'Like', photo_100: 'http://a.jpg' }],
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
if (url.includes('wall.getComments')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({
|
|
response: {
|
|
count: 2,
|
|
items: [
|
|
{ id: 1, from_id: 100, text: 'Comment from Alice' },
|
|
{ id: 2, from_id: 200, text: 'Comment from Bob' },
|
|
],
|
|
profiles: [
|
|
{ id: 100, first_name: 'Alice', last_name: 'Like' },
|
|
{ id: 200, first_name: 'Bob', last_name: 'Comment' },
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
return { ok: false, status: 404, text: async () => '' };
|
|
});
|
|
|
|
const provider = new VkProvider(serviceToken, client);
|
|
const participants = await provider.fetchParticipants({
|
|
ownerId: '-100',
|
|
postId: '1',
|
|
includeLikes: true,
|
|
includeComments: true,
|
|
});
|
|
|
|
expect(participants.length).toBe(2);
|
|
|
|
const alice = participants.find(p => p.platformUserId === '100');
|
|
expect(alice).toBeDefined();
|
|
expect(alice?.liked).toBe(true);
|
|
expect(alice?.commented).toBe(true);
|
|
expect(alice?.commentsCount).toBe(1);
|
|
|
|
const bob = participants.find(p => p.platformUserId === '200');
|
|
expect(bob).toBeDefined();
|
|
expect(bob?.liked).toBe(false);
|
|
expect(bob?.commented).toBe(true);
|
|
});
|
|
|
|
it('VkProvider batches subscription checks into 500-user chunks', async () => {
|
|
const userIds = Array.from({ length: 1200 }, (_, i) => String(1000 + i));
|
|
const capturedChunks: string[] = [];
|
|
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init: any) => {
|
|
const body = new URLSearchParams(init?.body || '');
|
|
const idsParam = body.get('user_ids') || '';
|
|
capturedChunks.push(idsParam);
|
|
|
|
const parsedIds = idsParam.split(',').map(Number);
|
|
const items = parsedIds.map(id => ({ user_id: id, member: id % 2 === 0 ? 1 : 0 }));
|
|
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({ response: items }),
|
|
};
|
|
});
|
|
|
|
const provider = new VkProvider(serviceToken, client);
|
|
const subMap = await provider.checkSubscription(userIds, '100');
|
|
|
|
expect(capturedChunks.length).toBe(3); // 500, 500, 200
|
|
expect(capturedChunks[0].split(',').length).toBe(500);
|
|
expect(capturedChunks[1].split(',').length).toBe(500);
|
|
expect(capturedChunks[2].split(',').length).toBe(200);
|
|
|
|
expect(subMap.size).toBe(1200);
|
|
expect(subMap.get('1000')).toBe(true);
|
|
expect(subMap.get('1001')).toBe(false);
|
|
});
|
|
|
|
it('ensures token is never exposed in logs or redaction utility', () => {
|
|
const secret = 'vk1.a.secret_top_secret_token_value_99999';
|
|
const redacted = redactToken(secret);
|
|
|
|
expect(redacted).not.toBe(secret);
|
|
expect(redacted).toContain('...');
|
|
expect(redacted.length).toBeLessThan(15);
|
|
});
|
|
});
|