feat(vk): Phase 2.1 Real VK Client Foundation - dedicated VkClient with typed errors, exponential backoff, rate limiting, token security, full pagination, and documentation

This commit is contained in:
Ochenstarik 2026-08-18 02:31:39 +07:00
parent a27341a302
commit e666161612
12 changed files with 1291 additions and 164 deletions

28
docs/VK_AUTH_MODEL.md Normal file
View file

@ -0,0 +1,28 @@
# VK Authentication Model & Token Security
This document outlines the authentication context lifecycle and security rules for VK tokens in **Randomayzer**.
---
## Token Types Supported
1. **Service Token (`SERVICE`)**:
- Used for public API read operations (`wall.getById`, `likes.getList`, `wall.getComments`, `groups.isMember`).
- Configured via environment variable `VK_SERVICE_TOKEN`.
- Never exposed to frontend clients.
2. **User Token (`USER`)**:
- Authorized via VK ID / OAuth with specific scopes (`wall`, `offline`, `groups`).
- Used when accessing non-public walls or private community groups with admin access.
3. **Community Token (`COMMUNITY`)**:
- Scoped to a specific community (`communityId`).
- Used for managing giveaways directly on behalf of a VK public page or group.
---
## Invariant Security Rules
- **Zero Logging**: Tokens are never passed to `console.log`, error messages, or telemetry.
- **Redaction Helper**: `redactToken(token)` masks tokens as `vk1.a...1234`.
- **Database & Audit Isolation**: Access tokens are **never** persisted to PostgreSQL or included in cryptographic AuditProof / DrawResult hashes.

55
docs/VK_CLIENT.md Normal file
View file

@ -0,0 +1,55 @@
# VK Client Architecture & Integration Guide
The VK Integration layer is structured into modular, decoupled components located under `src/integrations/vk/`.
---
## Architecture Overview
```
[SocialMediaProvider Interface]
[VkProvider]
[VkClient]
┌────────┼────────┐
│ │ │
▼ ▼ ▼
[VkAuth] [VkRateLimit] [VkRetry]
│ │ │
└────────┼────────┘
│ (POST https://api.vk.com/method/*)
[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`.
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.
3. **`VkRateLimiter` (`src/integrations/vk/vk-rate-limit.ts`)**:
- Throttles outbound requests according to VK API thresholds (default: 10 req/sec configurable).
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).
---
## Pagination & Scalability
- **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.

25
docs/VK_ERROR_MODEL.md Normal file
View file

@ -0,0 +1,25 @@
# VK API Error Model & Mapping
Randomayzer translates raw VK API response errors into typed error classes under `src/integrations/vk/vk-errors.ts`.
---
## Error Classification & Retry Matrix
| Error Class | Category | VK Error Code(s) | Retryable? | Description |
|---|---|---|---|---|
| `VkAuthError` | `AUTH` | 4, 5, 28 | **No** | Invalid, expired, or unauthorized access token. |
| `VkPermissionError` | `PERMISSION` | 7, 260 | **No** | Permission to perform action denied. |
| `VkPrivateResourceError` | `PRIVATE_RESOURCE` | 15, 30, 203 | **No** | Target profile, group, or wall post is private. |
| `VkNotFoundError` | `NOT_FOUND` | 104, 210, 214 | **No** | Post or community does not exist. |
| `VkValidationError` | `VALIDATION` | 100, 113, 150 | **No** | Invalid query parameters or bad request. |
| `VkRateLimitError` | `RATE_LIMIT` | 6, 9, 29 | **Yes** | Too many requests per second or flood control. |
| `VkTemporaryError` | `TEMPORARY` | 1, 10, 500..504 | **Yes** | Unknown or internal VK server error. |
| `VkNetworkError` | `NETWORK` | - | **Yes** | Socket error, connection drop, DNS resolution failure. |
| `VkTimeoutError` | `TIMEOUT` | - | **Yes** | Request exceeded timeout duration. |
---
## Token Redaction in Error Traces
All error messages and `request_params` returned by VK are passed through a strict sanitizer that redacts any `access_token` parameter before instantiating the error object.

View file

@ -0,0 +1,41 @@
import { VkAuthContext, VkTokenType } from './vk-types';
import { VkAuthError } from './vk-errors';
/**
* Safely redacts an access token for logging and diagnostic purposes.
*/
export function redactToken(token?: string | null): string {
if (!token || typeof token !== 'string') return '[NO_TOKEN]';
if (token.length <= 8) return '***';
return `${token.substring(0, 4)}...${token.substring(token.length - 4)}`;
}
/**
* Validates that an auth context is properly formatted and non-empty.
*/
export function validateAuthContext(auth: VkAuthContext): void {
if (!auth) {
throw new VkAuthError('VK AuthContext is missing');
}
if (!auth.token || typeof auth.token !== 'string' || auth.token.trim().length === 0) {
throw new VkAuthError(`VK ${auth.type || 'UNKNOWN'} access token is empty or invalid`);
}
if (auth.type === 'COMMUNITY' && !auth.communityId) {
throw new VkAuthError('VK COMMUNITY auth context requires a valid communityId');
}
}
/**
* Factory functions for building type-safe VK auth contexts
*/
export function createServiceAuth(token: string): VkAuthContext {
return { type: 'SERVICE', token };
}
export function createUserAuth(token: string): VkAuthContext {
return { type: 'USER', token };
}
export function createCommunityAuth(token: string, communityId: string): VkAuthContext {
return { type: 'COMMUNITY', token, communityId };
}

View file

@ -0,0 +1,227 @@
import {
VkAuthContext,
VkCallOptions,
VkApiResponse,
VkPaginationOptions
} from './vk-types';
import {
mapVkApiError,
VkTimeoutError,
VkNetworkError,
VkValidationError
} from './vk-errors';
import { validateAuthContext, redactToken } from './vk-auth';
import { IVkRateLimiter, defaultVkRateLimiter } from './vk-rate-limit';
import { executeWithRetry } from './vk-retry';
export interface IVkClient {
call<T>(
method: string,
params: Record<string, any>,
authContext: VkAuthContext,
options?: VkCallOptions
): Promise<T>;
}
export interface VkClientOptions {
apiVersion?: string;
baseUrl?: string;
defaultTimeoutMs?: number;
rateLimiter?: IVkRateLimiter;
}
export class VkClient implements IVkClient {
public static readonly DEFAULT_API_VERSION = '5.199';
public static readonly DEFAULT_BASE_URL = 'https://api.vk.com/method/';
public static readonly DEFAULT_TIMEOUT_MS = 15000;
private readonly apiVersion: string;
private readonly baseUrl: string;
private readonly defaultTimeoutMs: number;
private readonly rateLimiter: IVkRateLimiter;
constructor(options?: VkClientOptions) {
this.apiVersion = options?.apiVersion ?? VkClient.DEFAULT_API_VERSION;
this.baseUrl = options?.baseUrl ?? VkClient.DEFAULT_BASE_URL;
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? VkClient.DEFAULT_TIMEOUT_MS;
this.rateLimiter = options?.rateLimiter ?? defaultVkRateLimiter;
}
/**
* Executes a single low-level HTTP call to VK API with timeout and error handling.
*/
private async executeSingleCall<T>(
method: string,
params: Record<string, any>,
authContext: VkAuthContext,
options?: VkCallOptions
): Promise<T> {
validateAuthContext(authContext);
// Acquire rate limit slot
await this.rateLimiter.acquire();
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const controller = new AbortController();
let timeoutId: NodeJS.Timeout | null = null;
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
}
// Merge caller signal if provided
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 });
}
const url = `${this.baseUrl}${method}`;
const formBody = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
formBody.append(key, String(value));
}
}
formBody.append('v', this.apiVersion);
formBody.append('access_token', authContext.token);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Randomayzer/1.0 (+https://github.com/ochenstarik-ui/randomayzer)',
},
body: formBody.toString(),
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 }
);
}
const text = await response.text();
let json: VkApiResponse<T>;
try {
json = JSON.parse(text);
} catch {
throw new VkValidationError(
`VK API returned invalid non-JSON response for method ${method}`,
{ method }
);
}
if (json.error) {
throw mapVkApiError(json.error, method);
}
if (json.response === undefined) {
throw new VkValidationError(
`VK API response missing "response" payload for method ${method}`,
{ method }
);
}
return json.response;
} catch (err: unknown) {
if (timeoutId) clearTimeout(timeoutId);
if (controller.signal.aborted) {
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 (err instanceof Error && !(err instanceof VkNetworkError) && !(err as any).category) {
// Unhandled fetch network error
throw new VkNetworkError(`VK API network error on "${method}": ${err.message}`, {
method,
});
}
throw err;
}
}
/**
* Main typed method with automatic retry engine.
*/
public async call<T>(
method: string,
params: Record<string, any> = {},
authContext: VkAuthContext,
options?: VkCallOptions
): Promise<T> {
return executeWithRetry<T>(
() => this.executeSingleCall<T>(method, params, authContext, options),
{
maxRetries: options?.maxRetries,
initialDelayMs: options?.retryInitialDelayMs,
maxDelayMs: options?.retryMaxDelayMs,
},
options?.signal
);
}
}
/**
* Universal pagination abstraction for VK API methods (offset/count based).
*/
export async function fetchPaginatedVk<TItem>(
options: VkPaginationOptions<TItem>
): Promise<TItem[]> {
const pageSize = options.pageSize ?? 100;
const maxPages = options.maxPages ?? 10000;
const allItems: TItem[] = [];
let offset = 0;
let page = 0;
while (page < maxPages) {
if (options.signal?.aborted) {
break;
}
const { items, totalCount } = await options.fetchPage(offset, pageSize, options.signal);
if (!items || items.length === 0) {
break;
}
allItems.push(...items);
offset += items.length;
page++;
if (options.onProgress) {
options.onProgress(allItems.length, totalCount ?? null);
}
if (totalCount !== undefined && allItems.length >= totalCount) {
break;
}
if (items.length < pageSize) {
break;
}
}
return allItems;
}
export const defaultVkClient: IVkClient = new VkClient();

View file

@ -0,0 +1,158 @@
import { VkApiRawError } from './vk-types';
/**
* Base abstract class for all VK client errors.
* Guarantees that access tokens are never leaked into error messages or details.
*/
export abstract class VkClientError extends Error {
abstract readonly isRetryable: boolean;
abstract readonly category: string;
readonly errorCode?: number;
readonly method?: string;
readonly details?: any;
constructor(message: string, options?: { errorCode?: number; method?: string; details?: any }) {
super(message);
this.name = this.constructor.name;
this.errorCode = options?.errorCode;
this.method = options?.method;
this.details = options?.details;
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28)
*/
export class VkAuthError extends VkClientError {
readonly isRetryable = false;
readonly category = 'AUTH';
}
/**
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260)
*/
export class VkPermissionError extends VkClientError {
readonly isRetryable = false;
readonly category = 'PERMISSION';
}
/**
* VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9)
*/
export class VkRateLimitError extends VkClientError {
readonly isRetryable = true;
readonly category = 'RATE_LIMIT';
}
/**
* VK Private Resource Error: Target group/user profile is private or access is restricted (VK error codes: 15, 30, 203)
*/
export class VkPrivateResourceError extends VkClientError {
readonly isRetryable = false;
readonly category = 'PRIVATE_RESOURCE';
}
/**
* VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214)
*/
export class VkNotFoundError extends VkClientError {
readonly isRetryable = false;
readonly category = 'NOT_FOUND';
}
/**
* VK Validation Error: Malformed parameters or bad request (VK error codes: 100, 113, 150)
*/
export class VkValidationError extends VkClientError {
readonly isRetryable = false;
readonly category = 'VALIDATION';
}
/**
* VK Temporary Error: Unknown error, internal server error, or 5xx response from VK API (VK error codes: 1, 10, 500, 502, 503, 504)
*/
export class VkTemporaryError extends VkClientError {
readonly isRetryable = true;
readonly category = 'TEMPORARY';
}
/**
* VK Network Error: Connection refused, DNS failure, or aborted network socket
*/
export class VkNetworkError extends VkClientError {
readonly isRetryable = true;
readonly category = 'NETWORK';
}
/**
* VK Timeout Error: Request was aborted due to client timeout
*/
export class VkTimeoutError extends VkClientError {
readonly isRetryable = true;
readonly category = 'TIMEOUT';
}
/**
* Sanitizes request params returned by VK to redact any sensitive token values.
*/
function sanitizeRequestParams(params?: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> | undefined {
if (!params) return undefined;
return params.map(p => {
if (p.key.toLowerCase().includes('token') || p.key.toLowerCase().includes('access_token')) {
return { key: p.key, value: '[REDACTED]' };
}
return p;
});
}
/**
* Maps raw VK API error_code according to official VK API documentation.
*/
export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError {
const code = raw.error_code;
const sanitizedParams = sanitizeRequestParams(raw.request_params);
const msg = raw.error_msg || raw.error_text || `VK API Error (${code})`;
switch (code) {
case 4: // Incorrect signature
case 5: // User authorization failed
case 28: // Application authorization failed
return new VkAuthError(`VK Authentication Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 6: // Too many requests per second
case 9: // Flood control
case 29: // Rate limit reached
return new VkRateLimitError(`VK Rate Limit Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 7: // Permission to perform this action is denied
case 260: // Access to the group is denied
return new VkPermissionError(`VK Permission Denied (${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 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 });
}
}

View file

@ -0,0 +1,65 @@
export interface IVkRateLimiter {
acquire(key?: string): Promise<void>;
reset(): void;
}
export interface VkRateLimiterOptions {
maxRequestsPerSecond?: number;
minIntervalMs?: number;
}
/**
* Client-side rate limiter for throttling outbound requests to the VK API.
* Ensures the client stays within official VK API thresholds (default: 3 req/sec user, up to 20 req/sec service).
*/
export class VkRateLimiter implements IVkRateLimiter {
private lastRequestTime = 0;
private readonly minIntervalMs: number;
private queue: Array<() => void> = [];
private isProcessing = false;
constructor(options?: VkRateLimiterOptions) {
const rps = options?.maxRequestsPerSecond ?? 3;
this.minIntervalMs = options?.minIntervalMs ?? Math.ceil(1000 / rps);
}
public async acquire(): Promise<void> {
return new Promise<void>(resolve => {
this.queue.push(resolve);
this.processQueue();
});
}
private async processQueue(): Promise<void> {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
while (this.queue.length > 0) {
const now = Date.now();
const timeSinceLast = now - this.lastRequestTime;
const waitTime = Math.max(0, this.minIntervalMs - timeSinceLast);
if (waitTime > 0) {
await new Promise(r => setTimeout(r, waitTime));
}
this.lastRequestTime = Date.now();
const nextResolve = this.queue.shift();
if (nextResolve) {
nextResolve();
}
}
this.isProcessing = false;
}
public reset(): void {
this.queue = [];
this.lastRequestTime = 0;
this.isProcessing = false;
}
}
export const defaultVkRateLimiter: IVkRateLimiter = new VkRateLimiter({
maxRequestsPerSecond: 10,
});

View file

@ -0,0 +1,91 @@
import { VkClientError } from './vk-errors';
export interface VkRetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
factor?: number;
jitter?: boolean;
}
/**
* Calculates exponential backoff delay with optional full jitter.
*/
export function calculateBackoffDelay(
attempt: number,
options: Required<VkRetryOptions>
): number {
const baseDelay = options.initialDelayMs * Math.pow(options.factor, attempt);
const cappedDelay = Math.min(baseDelay, options.maxDelayMs);
if (options.jitter) {
// Full jitter: random delay between 0 and cappedDelay
return Math.floor(Math.random() * cappedDelay);
}
return Math.floor(cappedDelay);
}
/**
* Determines whether a given error is eligible for retry.
*/
export function isErrorRetryable(error: unknown): boolean {
if (error instanceof VkClientError) {
return error.isRetryable;
}
return false;
}
/**
* Executes an asynchronous operation with retry and backoff.
*/
export async function executeWithRetry<T>(
operation: (attempt: number) => Promise<T>,
options?: VkRetryOptions,
signal?: AbortSignal
): Promise<T> {
const config: Required<VkRetryOptions> = {
maxRetries: options?.maxRetries ?? 3,
initialDelayMs: options?.initialDelayMs ?? 300,
maxDelayMs: options?.maxDelayMs ?? 4000,
factor: options?.factor ?? 2,
jitter: options?.jitter ?? true,
};
let lastError: unknown;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
if (signal?.aborted) {
throw new Error('Operation aborted');
}
try {
return await operation(attempt);
} catch (err: unknown) {
lastError = err;
if (attempt === config.maxRetries || !isErrorRetryable(err)) {
throw err;
}
const delay = calculateBackoffDelay(attempt, config);
if (delay > 0) {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, delay);
if (signal) {
signal.addEventListener(
'abort',
() => {
clearTimeout(timeout);
reject(new Error('Operation aborted during retry backoff'));
},
{ once: true }
);
}
});
}
}
}
throw lastError;
}

View file

@ -0,0 +1,121 @@
/**
* VK Authentication context supporting Service, User, and Community access tokens.
*/
export type VkTokenType = 'SERVICE' | 'USER' | 'COMMUNITY';
export interface VkAuthContext {
type: VkTokenType;
token: string;
communityId?: string;
}
/**
* Raw VK API response shape according to official VK API specifications.
*/
export interface VkApiRawErrorParam {
key: string;
value: string;
}
export interface VkApiRawError {
error_code: number;
error_msg: string;
request_params?: VkApiRawErrorParam[];
error_text?: string;
}
export interface VkApiResponse<T> {
response?: T;
error?: VkApiRawError;
execute_errors?: VkApiRawError[];
}
/**
* Call options for individual VK client requests.
*/
export interface VkCallOptions {
timeoutMs?: number;
signal?: AbortSignal;
maxRetries?: number;
retryInitialDelayMs?: number;
retryMaxDelayMs?: number;
}
/**
* Base pagination parameters for VK endpoints.
*/
export interface VkPaginationOptions<TItem> {
fetchPage: (offset: number, count: number, signal?: AbortSignal) => Promise<{
items: TItem[];
totalCount?: number;
}>;
pageSize?: number;
maxPages?: number;
signal?: AbortSignal;
onProgress?: (loadedCount: number, totalCount: number | null) => void;
}
/**
* VK API Domain Entities
*/
export interface VkWallPost {
id: number;
owner_id: number;
from_id?: number;
date: number;
text: number | string;
comments?: { count: number };
likes?: { count: number; user_likes?: number };
reposts?: { count: number; user_reposted?: number };
attachments?: Array<{ type: string; [key: string]: any }>;
is_pinned?: number;
}
export interface VkUserProfile {
id: number;
first_name: string;
last_name: string;
screen_name?: string;
photo_50?: string;
photo_100?: string;
photo_200?: string;
deactivated?: string; // 'deleted' | 'banned'
is_closed?: boolean;
}
export interface VkGroupProfile {
id: number;
name: string;
screen_name: string;
is_closed: number;
type: 'group' | 'page' | 'event';
photo_50?: string;
}
export interface VkLikesGetListResponse {
count: number;
items: Array<number | VkUserProfile>;
}
export interface VkCommentItem {
id: number;
from_id: number;
date: number;
text: string;
likes?: { count: number };
deleted?: boolean;
}
export interface VkWallGetCommentsResponse {
count: number;
items: VkCommentItem[];
profiles?: VkUserProfile[];
groups?: VkGroupProfile[];
}
export interface VkIsMemberItem {
user_id: number;
member: number; // 1 or 0
invitation?: number;
request?: number;
}

View file

@ -2,14 +2,18 @@ import { PlatformType, PostMetadata } from '../../core/types/giveaway';
import { RawParticipant } from '../../core/types/participant';
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
import { parseVkPostUrl } from './vk-parser';
interface VkApiResponse<T> {
response?: T;
error?: {
error_code: number;
error_msg: string;
};
}
import { IVkClient, defaultVkClient, fetchPaginatedVk } from '@/integrations/vk/vk-client';
import { VkAuthContext } from '@/integrations/vk/vk-types';
import { createServiceAuth } from '@/integrations/vk/vk-auth';
import {
VkWallPost,
VkUserProfile,
VkGroupProfile,
VkLikesGetListResponse,
VkWallGetCommentsResponse,
VkIsMemberItem
} from '@/integrations/vk/vk-types';
import { VkNotFoundError, VkAuthError } from '@/integrations/vk/vk-errors';
export class VkProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK';
@ -23,82 +27,64 @@ export class VkProvider implements SocialMediaProvider {
adminDetectionNote: 'Требует расширенных прав администратора группы',
};
private serviceToken?: string;
private apiVersion = '5.199';
private baseUrl = 'https://api.vk.com/method';
private readonly client: IVkClient;
private readonly authContext: VkAuthContext;
constructor(serviceToken?: string) {
this.serviceToken = serviceToken || process.env.VK_SERVICE_TOKEN;
constructor(serviceToken?: string, client?: IVkClient) {
const token = serviceToken || process.env.VK_SERVICE_TOKEN;
if (!token) {
// In tests or unconfigured environments, create a dummy context that will be validated on call
this.authContext = createServiceAuth('');
} else {
this.authContext = createServiceAuth(token);
}
this.client = client || defaultVkClient;
}
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
public parsePostUrl(url: string): { ownerId: string; postId: string } | null {
return parseVkPostUrl(url);
}
private async callApi<T>(method: string, params: Record<string, string | number>): Promise<T> {
if (!this.serviceToken) {
throw new Error('VK_SERVICE_TOKEN is not configured in environment variables');
private ensureConfigured(): void {
if (!this.authContext.token) {
throw new VkAuthError('VK_SERVICE_TOKEN is not configured in environment variables');
}
const query = new URLSearchParams({
...Object.entries(params).reduce((acc, [k, v]) => ({ ...acc, [k]: String(v) }), {}),
access_token: this.serviceToken,
v: this.apiVersion,
});
const response = await fetch(`${this.baseUrl}/${method}?${query.toString()}`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`VK API HTTP error: ${response.status} ${response.statusText}`);
}
const data: VkApiResponse<T> = await response.json();
if (data.error) {
throw new Error(`VK API Error (${data.error.error_code}): ${data.error.error_msg}`);
}
if (!data.response) {
throw new Error('Empty response from VK API');
}
return data.response;
}
async fetchPost(url: string): Promise<PostMetadata> {
this.ensureConfigured();
const parsed = this.parsePostUrl(url);
if (!parsed) {
throw new Error('Invalid VK post URL format');
throw new VkNotFoundError('Invalid VK post URL format');
}
const { ownerId, postId } = parsed;
const response = await this.callApi<{ items: any[]; profiles?: any[]; groups?: any[] }>(
'wall.getById',
{
posts: `${ownerId}_${postId}`,
extended: 1,
}
);
const response = await this.client.call<{
items: VkWallPost[];
profiles?: VkUserProfile[];
groups?: VkGroupProfile[];
}>('wall.getById', {
posts: `${ownerId}_${postId}`,
extended: 1,
}, this.authContext);
if (!response.items || response.items.length === 0) {
throw new Error('Post not found or access is restricted');
throw new VkNotFoundError(`Post "${ownerId}_${postId}" not found or access is restricted`);
}
const post = response.items[0];
let authorName = `VK Wall ${ownerId}`;
let authorAvatarUrl = undefined;
let authorAvatarUrl: string | undefined = undefined;
if (ownerId.startsWith('-')) {
const groupId = Math.abs(parseInt(ownerId, 10));
const group = (response.groups || []).find(g => g.id === groupId);
if (group) {
authorName = group.name;
authorAvatarUrl = group.photo_100 || group.photo_200;
authorAvatarUrl = group.photo_50;
}
} else {
const userId = parseInt(ownerId, 10);
@ -109,22 +95,24 @@ export class VkProvider implements SocialMediaProvider {
}
}
let imageUrl = undefined;
let imageUrl: string | undefined = undefined;
if (post.attachments && post.attachments.length > 0) {
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
const photoAttachment = post.attachments.find(a => a.type === 'photo');
if (photoAttachment && photoAttachment.photo && photoAttachment.photo.sizes) {
const sizes = photoAttachment.photo.sizes;
imageUrl = sizes[sizes.length - 1]?.url;
}
}
const textContent = String(post.text || '');
return {
platform: 'VK',
ownerId,
postId,
sourceUrl: url,
title: post.text ? post.text.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
text: post.text || '',
title: textContent ? textContent.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
text: textContent,
authorName,
authorAvatarUrl,
imageUrl,
@ -136,126 +124,137 @@ export class VkProvider implements SocialMediaProvider {
}
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
this.ensureConfigured();
const { ownerId, postId } = params;
const participantsMap = new Map<string, RawParticipant>();
// 1. Fetch Likes
// 1. Fetch Likes with full pagination
if (params.includeLikes !== false) {
let offset = 0;
const count = 1000;
let totalLikes = 0;
const likeProfiles = await fetchPaginatedVk<VkUserProfile>({
pageSize: 100,
fetchPage: async (offset, count) => {
const res = await this.client.call<VkLikesGetListResponse>('likes.getList', {
type: 'post',
owner_id: ownerId,
item_id: postId,
filter: 'likes',
extended: 1,
count,
offset,
}, this.authContext);
do {
const likesRes = await this.callApi<{ count: number; items: any[] }>('likes.getList', {
type: 'post',
owner_id: ownerId,
item_id: postId,
filter: 'likes',
extended: 1,
count,
offset,
return {
items: (res.items || []) as VkUserProfile[],
totalCount: res.count,
};
},
onProgress: (loaded, total) => {
if (params.onProgress) {
params.onProgress(loaded, total ?? loaded, 'Загрузка лайков...');
}
},
});
for (const item of likeProfiles) {
const userId = String(item.id);
participantsMap.set(userId, {
platformUserId: userId,
firstName: item.first_name || '',
lastName: item.last_name || '',
username: item.screen_name || undefined,
avatarUrl: item.photo_100 || item.photo_200,
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false,
});
totalLikes = likesRes.count;
for (const item of likesRes.items) {
const userId = String(item.id);
participantsMap.set(userId, {
platformUserId: userId,
firstName: item.first_name || '',
lastName: item.last_name || '',
username: item.screen_name || undefined,
avatarUrl: item.photo_100 || item.photo_200,
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false,
});
}
offset += count;
if (params.onProgress) {
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
}
} while (offset < totalLikes && offset < 5000);
}
}
// 2. Fetch Comments
// 2. Fetch Comments with full pagination and author profile mapping
if (params.includeComments) {
let offset = 0;
const count = 100;
let totalComments = 0;
do {
const commentsRes = await this.callApi<{ count: number; items: any[]; profiles?: any[] }>(
'wall.getComments',
{
await fetchPaginatedVk<{ from_id: number }>({
pageSize: 100,
fetchPage: async (offset, count) => {
const res = await this.client.call<VkWallGetCommentsResponse>('wall.getComments', {
owner_id: ownerId,
post_id: postId,
extended: 1,
count,
offset,
fields: 'photo_100,photo_200,screen_name',
}
);
}, this.authContext);
totalComments = commentsRes.count;
const profileMap = new Map<number, any>(
(commentsRes.profiles || []).map(p => [p.id, p])
);
const profileMap = new Map<number, VkUserProfile>(
(res.profiles || []).map(p => [p.id, p])
);
for (const item of commentsRes.items) {
if (item.from_id && item.from_id > 0) {
const userId = String(item.from_id);
const prof = profileMap.get(item.from_id);
const existing = participantsMap.get(userId);
for (const item of res.items || []) {
if (item.from_id && item.from_id > 0) {
const userId = String(item.from_id);
const prof = profileMap.get(item.from_id);
const existing = participantsMap.get(userId);
if (existing) {
existing.commented = true;
existing.commentsCount = (existing.commentsCount || 0) + 1;
} else {
participantsMap.set(userId, {
platformUserId: userId,
firstName: prof?.first_name || 'Участник',
lastName: prof?.last_name || userId,
username: prof?.screen_name,
avatarUrl: prof?.photo_100,
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: false,
});
if (existing) {
existing.commented = true;
existing.commentsCount = (existing.commentsCount || 0) + 1;
} else {
participantsMap.set(userId, {
platformUserId: userId,
firstName: prof?.first_name || 'Участник',
lastName: prof?.last_name || userId,
username: prof?.screen_name,
avatarUrl: prof?.photo_100,
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: false,
});
}
}
}
}
offset += count;
} while (offset < totalComments && offset < 1000);
return {
items: res.items || [],
totalCount: res.count,
};
},
onProgress: () => {
if (params.onProgress) {
params.onProgress(participantsMap.size, participantsMap.size, 'Загрузка комментариев...');
}
},
});
}
return Array.from(participantsMap.values());
}
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
this.ensureConfigured();
const cleanGroupId = groupId.replace(/^-/, '');
const resultMap = new Map<string, boolean>();
// VK API groups.isMember allows up to 500 user_ids per batch call
const chunkSize = 500;
for (let i = 0; i < userIds.length; i += chunkSize) {
const chunk = userIds.slice(i, i + chunkSize);
const res = await this.callApi<Array<{ user_id: number; member: number }>>(
const res = await this.client.call<VkIsMemberItem[]>(
'groups.isMember',
{
group_id: cleanGroupId,
user_ids: chunk.join(','),
}
},
this.authContext
);
for (const item of res) {
for (const item of res || []) {
resultMap.set(String(item.user_id), item.member === 1);
}
}

View file

@ -0,0 +1,312 @@
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);
});
});

View file

@ -1,5 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VkProvider } from '../src/providers/vk/vk-provider';
import { VkClient } from '../src/integrations/vk/vk-client';
import {
VkAuthError,
VkPermissionError,
VkRateLimitError,
VkPrivateResourceError,
VkNotFoundError,
VkValidationError,
VkNetworkError
} from '../src/integrations/vk/vk-errors';
describe('VK API error handling', () => {
const token = 'vk1.a.test-service-token';
@ -18,6 +28,7 @@ describe('VK API error handling', () => {
status,
statusText: status === 500 ? 'Internal Server Error' : 'OK',
json: async () => json,
text: async () => (typeof json === 'string' ? json : JSON.stringify(json)),
});
}
@ -29,110 +40,104 @@ describe('VK API error handling', () => {
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
const provider = new VkProvider(token);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/User authorization failed/);
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);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/User authorization failed/);
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);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Access denied/);
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' } });
const provider = new VkProvider(token);
// 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(/Too many requests/);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkRateLimitError);
});
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);
await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(/Rate limit reached/);
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);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/This profile is private/);
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);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/User was deleted or banned/);
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);
await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(/parameters specified was missing or invalid/);
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);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Post not found/);
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);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Access to the community is denied/);
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);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Empty response/);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkValidationError);
});
it('throws on HTTP 500 from VK', async () => {
mockFetchJson({}, 500);
mockFetchJson('Internal Server Error', 500);
const provider = new VkProvider(token);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/HTTP error: 500/);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
});
it('throws on network timeout / failure', async () => {
mockFetchNetworkError('fetch failed');
const provider = new VkProvider(token);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/fetch failed/);
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);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
try {
await provider.fetchPost('https://vk.com/wall-1_1');
expect.unreachable('Should have thrown');
} catch (err: any) {
expect(err.message).not.toContain(token);
}
});
it('does not retry transient errors by default', async () => {
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
const provider = new VkProvider(token);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
expect(global.fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
});
});