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 {} 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(data: T) { (global.fetch as ReturnType).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).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).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).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).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).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).mockResolvedValue({ ok: true, status: 200, text: async () => '502 Bad Gateway', 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({ 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).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).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); }); });