feat(vk): Phase 2.1.1 VK Client Correctness & Official VK Contract Alignment - separated timeout vs cancellation state with VkCancelledError, HTTP vs VK error separation based on VKCOM/vk-api-schema, listener cleanup, pagination truncation guards, and VK_METHOD_CAPABILITIES documentation
This commit is contained in:
parent
e666161612
commit
7acf4d2d4e
7 changed files with 605 additions and 92 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# VK Client Architecture & Integration Guide
|
||||
# VK Client Architecture & Official API Specification
|
||||
|
||||
The VK Integration layer is structured into modular, decoupled components located under `src/integrations/vk/`.
|
||||
The VK Integration layer is structured into decoupled components located under `src/integrations/vk/`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -25,31 +25,51 @@ The VK Integration layer is structured into modular, decoupled components locate
|
|||
[VK API]
|
||||
```
|
||||
|
||||
### Core Components
|
||||
---
|
||||
|
||||
1. **`VkClient` (`src/integrations/vk/vk-client.ts`)**:
|
||||
- Centralizes low-level HTTP communication with VK API.
|
||||
- Enforces default API version `5.199`.
|
||||
- Manages timeouts with `AbortController` (default 15s).
|
||||
- Coordinates outbound rate limiting and retry backoff.
|
||||
- Provides universal pagination helper `fetchPaginatedVk`.
|
||||
## Verified VK API Specifications (v5.199)
|
||||
|
||||
2. **`VkAuthContext` (`src/integrations/vk/vk-auth.ts`)**:
|
||||
- Represents typed access tokens (`SERVICE`, `USER`, `COMMUNITY`).
|
||||
- Ensures tokens are never leaked into logs, error messages, or persistent audit records.
|
||||
### 1. `wall.getById`
|
||||
- **Official Docs**: `https://dev.vk.com/ru/method/wall.getById`
|
||||
- **Method**: POST/GET `https://api.vk.com/method/wall.getById`
|
||||
- **Parameters**: `posts` (e.g. `"-100_12345"`), `extended=1`.
|
||||
- **Response**: `{ items: VkWallPost[], profiles?: VkUserProfile[], groups?: VkGroupProfile[] }`.
|
||||
- **Behavior**: Returns empty `items: []` or error 210 if post is deleted or wall is private.
|
||||
|
||||
3. **`VkRateLimiter` (`src/integrations/vk/vk-rate-limit.ts`)**:
|
||||
- Throttles outbound requests according to VK API thresholds (default: 10 req/sec configurable).
|
||||
### 2. `likes.getList`
|
||||
- **Official Docs**: `https://dev.vk.com/ru/method/likes.getList`
|
||||
- **Method**: POST/GET `https://api.vk.com/method/likes.getList`
|
||||
- **Parameters**: `type="post"`, `owner_id`, `item_id`, `filter="likes"`, `extended=1`, `count` (max 100), `offset`.
|
||||
- **Response**: `{ count: number, items: VkUserProfile[] }`.
|
||||
|
||||
4. **`executeWithRetry` (`src/integrations/vk/vk-retry.ts`)**:
|
||||
- Handles exponential backoff with full jitter for retryable transient errors (5xx server errors, rate limits, network timeouts).
|
||||
- Fast-fails non-retryable errors (auth errors, permissions, private resources, validation).
|
||||
### 3. `wall.getComments`
|
||||
- **Official Docs**: `https://dev.vk.com/ru/method/wall.getComments`
|
||||
- **Method**: POST/GET `https://api.vk.com/method/wall.getComments`
|
||||
- **Parameters**: `owner_id`, `post_id`, `extended=1`, `count` (max 100), `offset`, `fields="photo_100,photo_200,screen_name"`.
|
||||
- **Response**: `{ count: number, items: VkCommentItem[], profiles?: VkUserProfile[] }`.
|
||||
|
||||
### 4. `groups.isMember`
|
||||
- **Official Docs**: `https://dev.vk.com/ru/method/groups.isMember`
|
||||
- **Method**: POST/GET `https://api.vk.com/method/groups.isMember`
|
||||
- **Parameters**: `group_id`, `user_ids` (comma-separated list of IDs up to **500 max** per batch call).
|
||||
- **Response**: `Array<{ user_id: number, member: 1 | 0 }>`.
|
||||
|
||||
### 5. Reposts Limitation `[CONFIRMED_LIMITATION]`
|
||||
- **Official Status**: VK API does **not** provide a public method to list all users who reposted an arbitrary third-party post due to user privacy settings. `wall.getReposts` only works for community managers on their own wall posts.
|
||||
- **Provider Flag**: `capabilities.reposts = false`.
|
||||
|
||||
### 6. Admin Detection `[UNVERIFIED]`
|
||||
- **Official Status**: Checking if a user is an administrator of a target community requires `groups.getMembers` with `filter=managers`, which requires community admin rights.
|
||||
- **Provider Flag**: `capabilities.adminDetection = false`.
|
||||
|
||||
---
|
||||
|
||||
## Pagination & Scalability
|
||||
## Cancellation vs Timeout Lifecycle
|
||||
|
||||
- **No Artificial Caps**: Previous limits (e.g. 5,000 likes or 1,000 comments) have been completely removed.
|
||||
- **Likes**: Uses `likes.getList` with `filter=likes&extended=1` in batches of 100 up to the total post likes count.
|
||||
- **Comments**: Uses `wall.getComments` with `extended=1` and profile enrichment.
|
||||
- **Subscription Checks**: Batches up to 500 user IDs per `groups.isMember` call.
|
||||
| Failure Mode | Error Class | Retryable? | Behavior |
|
||||
|---|---|---|---|
|
||||
| Caller `AbortSignal` fires | `VkCancelledError` | **No** | Request aborted immediately; retry engine halts without retry. |
|
||||
| Client timeout timer expires | `VkTimeoutError` | **Yes** | Attempt aborted; backoff delay computed and retry initiated up to `maxRetries`. |
|
||||
| HTTP 429 Too Many Requests | `VkRateLimitError` | **Yes** | Retryable with backoff. |
|
||||
| HTTP 500..504 Server Error | `VkTemporaryError` | **Yes** | Retryable with backoff. |
|
||||
| HTTP 400/401/403/404 | `VkClientError` subclasses | **No** | Fast fail. |
|
||||
|
|
|
|||
41
docs/VK_METHOD_CAPABILITIES.md
Normal file
41
docs/VK_METHOD_CAPABILITIES.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# VK Method Capabilities Matrix
|
||||
|
||||
This document defines the verified VK API method capabilities, token scopes, and known platform constraints according to the official **`VKCOM/vk-api-schema`** (API version 5.199).
|
||||
|
||||
---
|
||||
|
||||
## Verified VK Method Matrix
|
||||
|
||||
| VK Method | Allowed Token Types (Schema) | Required Parameters | Max Batch / Count | Known Privacy & Policy Limitations | Verification Status |
|
||||
|---|---|---|---|---|---|
|
||||
| **`wall.getById`** | `service`, `user`, `group`, `open` | `posts` (e.g. `"-123_456"`) | Max 100 posts per call | Cannot access posts on private user walls or restricted groups without user/group authorization. | **`VERIFIED`** |
|
||||
| **`likes.getList`** | `service`, `user`, `group`, `open` | `type="post"`, `owner_id`, `item_id` | Max 100 with `extended=1` (profiles), max 1000 with IDs only | If a post is from a closed community, requires membership/access. Profiles with deleted accounts are returned with `deactivated` tag. | **`VERIFIED`** |
|
||||
| **`wall.getComments`** | `service`, `user`, `group`, `open` | `owner_id`, `post_id` | Max 100 comments per request | Nested replies require recursive traversal or standard chronological fetch. Closed comments return error 210. | **`VERIFIED`** |
|
||||
| **`groups.isMember`** | `service`, `user`, `group`, `open` | `group_id`, `user_ids` | Max **500** `user_ids` per batch call | Closed groups return `member=0` for non-members even if user has pending join request (unless request status inspected). | **`VERIFIED`** |
|
||||
| **`wall.getReposts`** | `user`, `group` | `owner_id`, `post_id` | Max 100 | **Cannot enumerate all reposters** on arbitrary public posts due to user profile privacy restrictions. Only available to group managers for their own posts. | **`CONFIRMED_LIMITATION`** |
|
||||
| **`groups.getMembers`** (Managers) | `user`, `group` | `group_id`, `filter="managers"` | Max 1000 | Requires administrative rights in the target community. Not available via standalone public Service Token. | **`CONFIRMED_LIMITATION`** |
|
||||
|
||||
---
|
||||
|
||||
## Token Type Definitions (`VKCOM/vk-api-schema`)
|
||||
|
||||
1. **`service` (Service Token)**:
|
||||
- Application access token obtained from VK Developer Console.
|
||||
- Strictly read-only for public methods.
|
||||
- Never expires, but cannot act on behalf of a user.
|
||||
|
||||
2. **`user` (User Access Token)**:
|
||||
- Obtained via modern **VK ID Web SDK** (OAuth 2.1 with PKCE).
|
||||
- Can access user-authorized data, private groups user belongs to, and perform user actions.
|
||||
|
||||
3. **`group` (VK Group Access Token)**:
|
||||
- Configured in VK Community Settings (referred to internally as `COMMUNITY` token in Randomayzer).
|
||||
- Scoped strictly to the managing group/public page.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Architecture for Phase 2.2
|
||||
|
||||
- **Protocol**: OAuth 2.1 + PKCE (`code_verifier`, `code_challenge` SHA-256 base64url).
|
||||
- **State Security**: Cryptographically secure single-use `state` with TTL, validated on OAuth callback.
|
||||
- **Endpoints**: Modern `id.vk.com` / `vk.ru` VK ID Web SDK flow (legacy implicit token flow is deprecated).
|
||||
|
|
@ -6,11 +6,14 @@ import {
|
|||
} from './vk-types';
|
||||
import {
|
||||
mapVkApiError,
|
||||
mapHttpStatusError,
|
||||
VkTimeoutError,
|
||||
VkCancelledError,
|
||||
VkNetworkError,
|
||||
VkValidationError
|
||||
VkValidationError,
|
||||
VkPaginationLimitError
|
||||
} from './vk-errors';
|
||||
import { validateAuthContext, redactToken } from './vk-auth';
|
||||
import { validateAuthContext } from './vk-auth';
|
||||
import { IVkRateLimiter, defaultVkRateLimiter } from './vk-rate-limit';
|
||||
import { executeWithRetry } from './vk-retry';
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ export class VkClient implements IVkClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Executes a single low-level HTTP call to VK API with timeout and error handling.
|
||||
* Executes a single low-level HTTP call to VK API with explicit separate timeout/cancellation tracking.
|
||||
*/
|
||||
private async executeSingleCall<T>(
|
||||
method: string,
|
||||
|
|
@ -58,26 +61,42 @@ export class VkClient implements IVkClient {
|
|||
): Promise<T> {
|
||||
validateAuthContext(authContext);
|
||||
|
||||
// Acquire rate limit slot
|
||||
// 1. Check if caller already aborted before execution starts
|
||||
if (options?.signal?.aborted) {
|
||||
throw new VkCancelledError(`VK request to "${method}" was cancelled by caller before execution`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Acquire rate limit slot
|
||||
await this.rateLimiter.acquire();
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new VkCancelledError(`VK request to "${method}" was cancelled by caller while waiting for rate limiter`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let callerCancelled = false;
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Merge caller signal if provided
|
||||
const onCallerAbort = () => {
|
||||
callerCancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
throw new Error('VK request aborted by caller signal');
|
||||
}
|
||||
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
options.signal.addEventListener('abort', onCallerAbort, { once: true });
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${method}`;
|
||||
|
|
@ -103,13 +122,8 @@ export class VkClient implements IVkClient {
|
|||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VkNetworkError(
|
||||
`VK API HTTP error ${response.status}: ${response.statusText}`,
|
||||
{ method, errorCode: response.status }
|
||||
);
|
||||
throw mapHttpStatusError(response.status, response.statusText, method);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
|
@ -137,26 +151,42 @@ export class VkClient implements IVkClient {
|
|||
|
||||
return json.response;
|
||||
} catch (err: unknown) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
// Precise error classification:
|
||||
if (callerCancelled || options?.signal?.aborted) {
|
||||
throw new VkCancelledError(`VK API call to "${method}" was cancelled by the caller`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
if (timedOut) {
|
||||
throw new VkTimeoutError(`VK API call to "${method}" timed out after ${timeoutMs}ms`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new VkTimeoutError(`VK API call to "${method}" was aborted`, { method });
|
||||
if (options?.signal?.aborted) {
|
||||
throw new VkCancelledError(`VK API call to "${method}" was aborted by caller signal`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
throw new VkTimeoutError(`VK API call to "${method}" was aborted by timeout`, { method });
|
||||
}
|
||||
|
||||
if (err instanceof Error && !(err instanceof VkNetworkError) && !(err as any).category) {
|
||||
// Unhandled fetch network error
|
||||
if (err instanceof Error && !(err as any).category) {
|
||||
throw new VkNetworkError(`VK API network error on "${method}": ${err.message}`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener('abort', onCallerAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,20 +215,25 @@ export class VkClient implements IVkClient {
|
|||
* Universal pagination abstraction for VK API methods (offset/count based).
|
||||
*/
|
||||
export async function fetchPaginatedVk<TItem>(
|
||||
options: VkPaginationOptions<TItem>
|
||||
options: VkPaginationOptions<TItem> & { throwOnTruncation?: boolean }
|
||||
): Promise<TItem[]> {
|
||||
const pageSize = options.pageSize ?? 100;
|
||||
const maxPages = options.maxPages ?? 10000;
|
||||
const throwOnTruncation = options.throwOnTruncation ?? true;
|
||||
const allItems: TItem[] = [];
|
||||
let offset = 0;
|
||||
let page = 0;
|
||||
let recordedTotalCount: number | undefined;
|
||||
|
||||
while (page < maxPages) {
|
||||
if (options.signal?.aborted) {
|
||||
break;
|
||||
throw new VkCancelledError('VK pagination cancelled by caller signal');
|
||||
}
|
||||
|
||||
const { items, totalCount } = await options.fetchPage(offset, pageSize, options.signal);
|
||||
if (totalCount !== undefined) {
|
||||
recordedTotalCount = totalCount;
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
break;
|
||||
|
|
@ -221,6 +256,20 @@ export async function fetchPaginatedVk<TItem>(
|
|||
}
|
||||
}
|
||||
|
||||
// Safety check against silent truncation
|
||||
if (
|
||||
page >= maxPages &&
|
||||
recordedTotalCount !== undefined &&
|
||||
allItems.length < recordedTotalCount
|
||||
) {
|
||||
if (throwOnTruncation) {
|
||||
throw new VkPaginationLimitError(
|
||||
`VK pagination reached maxPages safety ceiling (${maxPages} pages) with only ${allItems.length}/${recordedTotalCount} items loaded. Truncation detected.`,
|
||||
{ details: { loaded: allItems.length, total: recordedTotalCount, maxPages } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export abstract class VkClientError extends Error {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28)
|
||||
* VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28, HTTP 401)
|
||||
*/
|
||||
export class VkAuthError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
|
|
@ -30,7 +30,7 @@ export class VkAuthError extends VkClientError {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260)
|
||||
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260, HTTP 403)
|
||||
*/
|
||||
export class VkPermissionError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
|
|
@ -38,7 +38,7 @@ export class VkPermissionError extends VkClientError {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9)
|
||||
* VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9, 29, HTTP 429)
|
||||
*/
|
||||
export class VkRateLimitError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
|
|
@ -54,7 +54,7 @@ export class VkPrivateResourceError extends VkClientError {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214)
|
||||
* VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214, HTTP 404)
|
||||
*/
|
||||
export class VkNotFoundError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
|
|
@ -62,7 +62,7 @@ export class VkNotFoundError extends VkClientError {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Validation Error: Malformed parameters or bad request (VK error codes: 100, 113, 150)
|
||||
* VK Validation Error: Malformed parameters or bad request (VK error codes: 8, 100, 113, 150, HTTP 400)
|
||||
*/
|
||||
export class VkValidationError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
|
|
@ -70,7 +70,7 @@ export class VkValidationError extends VkClientError {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Temporary Error: Unknown error, internal server error, or 5xx response from VK API (VK error codes: 1, 10, 500, 502, 503, 504)
|
||||
* VK Temporary Error: Unknown error or internal server error from VK API (VK error codes: 1, 10, HTTP 500, 502, 503, 504)
|
||||
*/
|
||||
export class VkTemporaryError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
|
|
@ -86,13 +86,32 @@ export class VkNetworkError extends VkClientError {
|
|||
}
|
||||
|
||||
/**
|
||||
* VK Timeout Error: Request was aborted due to client timeout
|
||||
* VK Timeout Error: Request was aborted due to internal client timeout or VK method timeout (VK error code: 36)
|
||||
*/
|
||||
export class VkTimeoutError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
readonly category = 'TIMEOUT';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Cancelled Error: Request was cancelled by the caller via AbortSignal (NEVER retryable)
|
||||
*/
|
||||
export class VkCancelledError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'CANCELLED';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Pagination Limit Error: Pagination hit maxPages safety threshold before fetching all items
|
||||
*/
|
||||
export class VkPaginationLimitError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'PAGINATION_LIMIT_REACHED';
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility
|
||||
export const VkPaginationTruncatedError = VkPaginationLimitError;
|
||||
|
||||
/**
|
||||
* Sanitizes request params returned by VK to redact any sensitive token values.
|
||||
*/
|
||||
|
|
@ -107,7 +126,7 @@ function sanitizeRequestParams(params?: Array<{ key: string; value: string }>):
|
|||
}
|
||||
|
||||
/**
|
||||
* Maps raw VK API error_code according to official VK API documentation.
|
||||
* Maps raw VK API error_code strictly according to official VKCOM/vk-api-schema.
|
||||
*/
|
||||
export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError {
|
||||
const code = raw.error_code;
|
||||
|
|
@ -115,6 +134,10 @@ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError
|
|||
const msg = raw.error_msg || raw.error_text || `VK API Error (${code})`;
|
||||
|
||||
switch (code) {
|
||||
case 1: // Unknown error occurred
|
||||
case 10: // Internal server error
|
||||
return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 4: // Incorrect signature
|
||||
case 5: // User authorization failed
|
||||
case 28: // Application authorization failed
|
||||
|
|
@ -129,30 +152,54 @@ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError
|
|||
case 260: // Access to the group is denied
|
||||
return new VkPermissionError(`VK Permission Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 8: // Invalid request
|
||||
case 100: // One of the parameters specified was missing or invalid
|
||||
case 113: // Invalid user id
|
||||
case 150: // Invalid timestamp
|
||||
return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 15: // Access denied (private object)
|
||||
case 30: // This profile is private
|
||||
case 203: // Access to the group is denied
|
||||
return new VkPrivateResourceError(`VK Private Resource Access Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 100: // One of the parameters specified was missing or invalid
|
||||
case 113: // Invalid user id
|
||||
case 150: // Invalid timestamp
|
||||
return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
case 36: // Compile or method execution timeout on VK server side
|
||||
return new VkTimeoutError(`VK Method Execution Timeout (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 104: // Not found
|
||||
case 210: // Access to wall's post denied or post not found
|
||||
case 214: // Access to adding post denied / not found
|
||||
return new VkNotFoundError(`VK Resource Not Found (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 1: // Unknown error occurred
|
||||
case 10: // Internal server error
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
default:
|
||||
return new VkValidationError(`VK API Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps HTTP status codes into typed VK client errors with proper retry classification.
|
||||
*/
|
||||
export function mapHttpStatusError(status: number, statusText: string, method: string): VkClientError {
|
||||
const msg = `VK API HTTP error ${status}: ${statusText || 'Unknown'}`;
|
||||
|
||||
if (status === 429) {
|
||||
return new VkRateLimitError(msg, { errorCode: status, method });
|
||||
}
|
||||
if (status >= 500 && status <= 504) {
|
||||
return new VkTemporaryError(msg, { errorCode: status, method });
|
||||
}
|
||||
if (status === 401) {
|
||||
return new VkAuthError(msg, { errorCode: status, method });
|
||||
}
|
||||
if (status === 403) {
|
||||
return new VkPermissionError(msg, { errorCode: status, method });
|
||||
}
|
||||
if (status === 404) {
|
||||
return new VkNotFoundError(msg, { errorCode: status, method });
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
return new VkValidationError(msg, { errorCode: status, method });
|
||||
}
|
||||
|
||||
return new VkNetworkError(msg, { errorCode: status, method });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { VkClientError } from './vk-errors';
|
||||
import { VkClientError, VkCancelledError } from './vk-errors';
|
||||
|
||||
export interface VkRetryOptions {
|
||||
maxRetries?: number;
|
||||
|
|
@ -56,7 +56,7 @@ export async function executeWithRetry<T>(
|
|||
|
||||
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation aborted');
|
||||
throw new VkCancelledError('Operation aborted by caller signal before attempt');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -71,16 +71,19 @@ export async function executeWithRetry<T>(
|
|||
const delay = calculateBackoffDelay(attempt, config);
|
||||
if (delay > 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, delay);
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
const onAbort = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
reject(new VkCancelledError('Operation aborted by caller signal during retry backoff'));
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
}, delay);
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Operation aborted during retry backoff'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
343
tests/vk-correctness-gate.test.ts
Normal file
343
tests/vk-correctness-gate.test.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
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]');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -8,14 +8,26 @@ import {
|
|||
VkPrivateResourceError,
|
||||
VkNotFoundError,
|
||||
VkValidationError,
|
||||
VkTemporaryError,
|
||||
VkNetworkError
|
||||
} 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 API error handling', () => {
|
||||
const token = 'vk1.a.test-service-token';
|
||||
let client: VkClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
client = new VkClient({
|
||||
rateLimiter: new NoopRateLimiter(),
|
||||
defaultTimeoutMs: 100,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -38,29 +50,27 @@ describe('VK API error handling', () => {
|
|||
|
||||
it('throws on invalid token (error_code 5)', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkAuthError);
|
||||
});
|
||||
|
||||
it('throws on expired token (error_code 5)', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkAuthError);
|
||||
});
|
||||
|
||||
it('throws on access denied (error_code 15)', async () => {
|
||||
mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError);
|
||||
});
|
||||
|
||||
it('throws on rate limit per second (error_code 6)', async () => {
|
||||
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
|
||||
// Use VkClient with 0 retries to observe immediate error
|
||||
const client = new VkClient();
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkRateLimitError);
|
||||
|
|
@ -68,70 +78,70 @@ describe('VK API error handling', () => {
|
|||
|
||||
it('throws on daily rate limit (error_code 29)', async () => {
|
||||
mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(VkRateLimitError);
|
||||
});
|
||||
|
||||
it('throws on private profile (error_code 30)', async () => {
|
||||
mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkPrivateResourceError);
|
||||
});
|
||||
|
||||
it('throws on deleted or banned user (error_code 18)', async () => {
|
||||
mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('throws on deleted post (error_code 100 with post not found semantics)', async () => {
|
||||
mockFetchJson({ error: { error_code: 100, error_msg: 'One of the parameters specified was missing or invalid' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('throws when post is not found in response', async () => {
|
||||
mockFetchJson({ response: { items: [] } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkNotFoundError);
|
||||
});
|
||||
|
||||
it('throws on unavailable community (error_code 203)', async () => {
|
||||
mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError);
|
||||
});
|
||||
|
||||
it('throws on empty response body', async () => {
|
||||
mockFetchJson({});
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('throws on HTTP 500 from VK', async () => {
|
||||
mockFetchJson('Internal Server Error', 500);
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkTemporaryError);
|
||||
});
|
||||
|
||||
it('throws on network timeout / failure', async () => {
|
||||
mockFetchNetworkError('fetch failed');
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
|
||||
});
|
||||
|
||||
it('does not leak the service token in thrown error messages', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
try {
|
||||
await provider.fetchPost('https://vk.com/wall-1_1');
|
||||
|
|
|
|||
Loading…
Reference in a new issue