import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { VkClient, fetchPaginatedVk } from '../src/integrations/vk/vk-client'; import { createServiceAuth, redactToken } from '../src/integrations/vk/vk-auth'; import { VkCancelledError, VkTimeoutError, VkRateLimitError, VkTemporaryError, VkAuthError, VkPermissionError, VkPrivateResourceError, VkNotFoundError, VkValidationError, VkPaginationLimitError } from '../src/integrations/vk/vk-errors'; import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit'; class ImmediateRateLimiter implements IVkRateLimiter { async acquire(): Promise {} reset(): void {} } describe('Phase 2.1.1 VK Client Correctness Gate & Official Schema Verification', () => { const token = 'vk1.a.correctness_gate_secret_token_12345'; const auth = createServiceAuth(token); let client: VkClient; beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); client = new VkClient({ rateLimiter: new ImmediateRateLimiter(), defaultTimeoutMs: 500, }); }); afterEach(() => { vi.unstubAllGlobals(); }); 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 } }), }); } // --- 1. Cancellation vs Timeout Separation --- it('caller already aborted before call immediately throws VkCancelledError with 0 retries', async () => { const controller = new AbortController(); controller.abort(); let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; return { ok: true, status: 200, text: async () => '{"response": 1}' }; }); await expect( client.call('wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3 }) ).rejects.toThrow(VkCancelledError); expect(fetchCount).toBe(0); }); it('caller abort during active request throws VkCancelledError and NEVER retries', async () => { const controller = new AbortController(); let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async (_url, init) => { fetchCount++; return new Promise((_, reject) => { init.signal.addEventListener('abort', () => { reject(new Error('AbortError')); }); }); }); const callPromise = client.call('wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3 }); setTimeout(() => controller.abort(), 10); await expect(callPromise).rejects.toThrow(VkCancelledError); expect(fetchCount).toBe(1); }); it('internal timeout throws VkTimeoutError and retries according to policy', async () => { const timeoutClient = new VkClient({ rateLimiter: new ImmediateRateLimiter(), defaultTimeoutMs: 25, }); let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; if (fetchCount < 3) { return new Promise(resolve => setTimeout(resolve, 80)); } return { ok: true, status: 200, text: async () => JSON.stringify({ response: { success: 1 } }), }; }); const result = await timeoutClient.call<{ success: number }>( 'wall.getById', {}, auth, { maxRetries: 3, retryInitialDelayMs: 5 } ); expect(result.success).toBe(1); expect(fetchCount).toBe(3); }); it('caller abort during retry backoff terminates immediately with VkCancelledError', async () => { const controller = new AbortController(); let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; return { ok: false, status: 500, statusText: 'Internal Server Error', }; }); const callPromise = client.call( 'wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3, retryInitialDelayMs: 300 } ); setTimeout(() => controller.abort(), 20); await expect(callPromise).rejects.toThrow(VkCancelledError); expect(fetchCount).toBe(1); }); // --- 2. HTTP Status Code Handling --- it('HTTP 429 Too Many Requests throws VkRateLimitError and retries', async () => { let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; if (fetchCount < 2) { return { ok: false, status: 429, statusText: 'Too Many Requests' }; } return { ok: true, status: 200, text: async () => JSON.stringify({ response: { ok: true } }), }; }); const res = await client.call<{ ok: boolean }>('likes.getList', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); expect(res.ok).toBe(true); expect(fetchCount).toBe(2); }); it('HTTP 500, 502, 503 throw VkTemporaryError and retry', async () => { for (const status of [500, 502, 503]) { let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; if (fetchCount < 2) { return { ok: false, status, statusText: 'Server Error' }; } return { ok: true, status: 200, text: async () => JSON.stringify({ response: { ok: true } }), }; }); const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); expect(res.ok).toBe(true); expect(fetchCount).toBe(2); } }); it('HTTP 400 throws VkValidationError and does NOT retry', async () => { let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; return { ok: false, status: 400, statusText: 'Bad Request' }; }); await expect(client.call('wall.getById', {}, auth, { maxRetries: 3 })).rejects.toThrow(VkValidationError); expect(fetchCount).toBe(1); }); // --- 3. Official VK API Error Codes --- it('VK error 5 (user auth) & 28 (app auth) throw VkAuthError without retry', async () => { mockFetchVkError(5, 'User authorization failed'); await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkAuthError); mockFetchVkError(28, 'Application authorization failed'); await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkAuthError); }); it('VK error 6 (too many req/s), 9 (flood control), 29 (rate limit) throw VkRateLimitError and retry', async () => { for (const code of [6, 9, 29]) { let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; if (fetchCount < 2) { return { ok: true, status: 200, text: async () => JSON.stringify({ error: { error_code: code, error_msg: 'Rate limited' } }), }; } return { ok: true, status: 200, text: async () => JSON.stringify({ response: { ok: true } }), }; }); const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); expect(res.ok).toBe(true); expect(fetchCount).toBe(2); } }); it('VK error 10 (internal server error) throws VkTemporaryError and retries', async () => { let fetchCount = 0; (global.fetch as ReturnType).mockImplementation(async () => { fetchCount++; if (fetchCount < 2) { return { ok: true, status: 200, text: async () => JSON.stringify({ error: { error_code: 10, error_msg: 'Internal error' } }), }; } return { ok: true, status: 200, text: async () => JSON.stringify({ response: { ok: true } }), }; }); const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); expect(res.ok).toBe(true); expect(fetchCount).toBe(2); }); it('VK error 7 (permission denied) & 15 (access denied) & 30 (private profile) throw non-retryable errors', async () => { mockFetchVkError(7, 'Permission denied'); await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPermissionError); mockFetchVkError(15, 'Access denied'); await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPrivateResourceError); mockFetchVkError(30, 'Private profile'); await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPrivateResourceError); }); it('VK error 100 (missing/invalid param) throws VkValidationError without retry', async () => { mockFetchVkError(100, 'One of the parameters specified was missing or invalid'); await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkValidationError); }); // --- 4. Pagination Truncation Safety --- it('pagination throws VkPaginationLimitError when maxPages is hit before totalCount is loaded', async () => { const fetchPage = vi.fn(async () => { return { items: [1, 2], totalCount: 10 }; }); await expect( fetchPaginatedVk({ pageSize: 2, maxPages: 2, fetchPage, throwOnTruncation: true, }) ).rejects.toThrow(VkPaginationLimitError); }); // --- 5. Malformed Response & Missing Response --- it('throws VkValidationError on malformed non-JSON response', async () => { (global.fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, text: async () => '{ broken_json ', }); await expect(client.call('wall.getById', {}, auth, { maxRetries: 0 })).rejects.toThrow(VkValidationError); }); it('throws VkValidationError on missing response field in VK JSON', async () => { (global.fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({}), }); await expect(client.call('wall.getById', {}, auth, { maxRetries: 0 })).rejects.toThrow(VkValidationError); }); // --- 6. Token Redaction Verification --- it('verifies token is never exposed in error message or error details', async () => { const secretToken = 'vk1.a.ultra_secret_token_999999999'; const authSecret = createServiceAuth(secretToken); (global.fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ error: { error_code: 5, error_msg: 'User authorization failed: invalid access_token.', request_params: [ { key: 'oauth', value: '1' }, { key: 'access_token', value: secretToken }, ], }, }), }); try { await client.call('wall.getById', {}, authSecret, { maxRetries: 0 }); expect.unreachable('Should have thrown VkAuthError'); } catch (err: any) { expect(err.message).not.toContain(secretToken); const detailsStr = JSON.stringify(err.details); expect(detailsStr).not.toContain(secretToken); expect(detailsStr).toContain('[REDACTED]'); } }); });