randomayzer/tests/vk-correctness-gate.test.ts

343 lines
11 KiB
TypeScript

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<void> {}
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<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 } }),
});
}
// --- 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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: 200 }
);
setTimeout(() => controller.abort(), 30);
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<number>({
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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]');
}
});
});