diff --git a/docs/VK_AUTH_MODEL.md b/docs/VK_AUTH_MODEL.md new file mode 100644 index 0000000..383e53d --- /dev/null +++ b/docs/VK_AUTH_MODEL.md @@ -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. diff --git a/docs/VK_CLIENT.md b/docs/VK_CLIENT.md new file mode 100644 index 0000000..801eadd --- /dev/null +++ b/docs/VK_CLIENT.md @@ -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. diff --git a/docs/VK_ERROR_MODEL.md b/docs/VK_ERROR_MODEL.md new file mode 100644 index 0000000..7b9bffe --- /dev/null +++ b/docs/VK_ERROR_MODEL.md @@ -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. diff --git a/src/integrations/vk/vk-auth.ts b/src/integrations/vk/vk-auth.ts new file mode 100644 index 0000000..03bc396 --- /dev/null +++ b/src/integrations/vk/vk-auth.ts @@ -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 }; +} diff --git a/src/integrations/vk/vk-client.ts b/src/integrations/vk/vk-client.ts new file mode 100644 index 0000000..9388698 --- /dev/null +++ b/src/integrations/vk/vk-client.ts @@ -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( + method: string, + params: Record, + authContext: VkAuthContext, + options?: VkCallOptions + ): Promise; +} + +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( + method: string, + params: Record, + authContext: VkAuthContext, + options?: VkCallOptions + ): Promise { + 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; + + 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( + method: string, + params: Record = {}, + authContext: VkAuthContext, + options?: VkCallOptions + ): Promise { + return executeWithRetry( + () => this.executeSingleCall(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( + options: VkPaginationOptions +): Promise { + 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(); diff --git a/src/integrations/vk/vk-errors.ts b/src/integrations/vk/vk-errors.ts new file mode 100644 index 0000000..0fd3b92 --- /dev/null +++ b/src/integrations/vk/vk-errors.ts @@ -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 }); + } +} diff --git a/src/integrations/vk/vk-rate-limit.ts b/src/integrations/vk/vk-rate-limit.ts new file mode 100644 index 0000000..2014154 --- /dev/null +++ b/src/integrations/vk/vk-rate-limit.ts @@ -0,0 +1,65 @@ +export interface IVkRateLimiter { + acquire(key?: string): Promise; + 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 { + return new Promise(resolve => { + this.queue.push(resolve); + this.processQueue(); + }); + } + + private async processQueue(): Promise { + 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, +}); diff --git a/src/integrations/vk/vk-retry.ts b/src/integrations/vk/vk-retry.ts new file mode 100644 index 0000000..6f76d18 --- /dev/null +++ b/src/integrations/vk/vk-retry.ts @@ -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 +): 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( + operation: (attempt: number) => Promise, + options?: VkRetryOptions, + signal?: AbortSignal +): Promise { + const config: Required = { + 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; +} diff --git a/src/integrations/vk/vk-types.ts b/src/integrations/vk/vk-types.ts new file mode 100644 index 0000000..f7bb636 --- /dev/null +++ b/src/integrations/vk/vk-types.ts @@ -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 { + 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 { + 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; +} + +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; +} diff --git a/src/providers/vk/vk-provider.ts b/src/providers/vk/vk-provider.ts index 45e0bac..928631f 100644 --- a/src/providers/vk/vk-provider.ts +++ b/src/providers/vk/vk-provider.ts @@ -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 { - 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(method: string, params: Record): Promise { - 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 = 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 { + 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 { + this.ensureConfigured(); + const { ownerId, postId } = params; const participantsMap = new Map(); - // 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({ + pageSize: 100, + fetchPage: async (offset, count) => { + const res = await this.client.call('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('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( - (commentsRes.profiles || []).map(p => [p.id, p]) - ); + const profileMap = new Map( + (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> { + this.ensureConfigured(); + const cleanGroupId = groupId.replace(/^-/, ''); const resultMap = new Map(); + // 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>( + const res = await this.client.call( '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); } } diff --git a/tests/vk-client-integration.test.ts b/tests/vk-client-integration.test.ts new file mode 100644 index 0000000..0662612 --- /dev/null +++ b/tests/vk-client-integration.test.ts @@ -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 {} + reset(): void {} +} + +describe('VK Client & Provider Mocked HTTP Integration', () => { + const serviceToken = 'vk1.a.secret_service_token_123456789'; + let client: VkClient; + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + client = new VkClient({ + rateLimiter: new NoopRateLimiter(), + defaultTimeoutMs: 1000, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockFetchSuccess(data: T) { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify({ response: data }), + json: async () => ({ response: data }), + }); + } + + function mockFetchVkError(errorCode: number, errorMsg: string) { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify({ error: { error_code: errorCode, error_msg: errorMsg } }), + json: async () => ({ error: { error_code: errorCode, error_msg: errorMsg } }), + }); + } + + it('successfully calls VK API and parses response payload', async () => { + mockFetchSuccess({ items: [{ id: 100, owner_id: -1, text: 'Hello VK post' }] }); + + const auth = createServiceAuth(serviceToken); + const result = await client.call<{ items: Array<{ id: number; text: string }> }>( + 'wall.getById', + { posts: '-1_100' }, + auth + ); + + expect(result.items).toBeDefined(); + expect(result.items[0].id).toBe(100); + expect(result.items[0].text).toBe('Hello VK post'); + }); + + it('supports User and Community auth context types', async () => { + mockFetchSuccess({ count: 1, items: [100] }); + + const userAuth = createUserAuth('vk1.a.user_token'); + const commAuth = createCommunityAuth('vk1.a.comm_token', '123456'); + + const resUser = await client.call('likes.getList', { type: 'post' }, userAuth); + expect(resUser).toBeDefined(); + + const resComm = await client.call('likes.getList', { type: 'post' }, commAuth); + expect(resComm).toBeDefined(); + }); + + it('throws VkTimeoutError when request exceeds configured timeout', async () => { + const slowClient = new VkClient({ + rateLimiter: new NoopRateLimiter(), + defaultTimeoutMs: 20, + }); + + (global.fetch as ReturnType).mockImplementation(() => + new Promise(resolve => setTimeout(resolve, 100)) + ); + + const auth = createServiceAuth(serviceToken); + await expect( + slowClient.call('wall.getById', { posts: '-1_1' }, auth, { maxRetries: 0 }) + ).rejects.toThrow(VkTimeoutError); + }); + + it('aborts immediately when caller AbortSignal fires', async () => { + const controller = new AbortController(); + (global.fetch as ReturnType).mockImplementation(() => + new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => reject(new Error('Caller aborted'))); + }) + ); + + const auth = createServiceAuth(serviceToken); + const promise = client.call('wall.getById', {}, auth, { signal: controller.signal }); + controller.abort(); + + await expect(promise).rejects.toThrow(); + }); + + it('retries on retryable server error (error_code 10) up to maxRetries', async () => { + let callCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + callCount++; + if (callCount < 3) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ error: { error_code: 10, error_msg: 'Internal server error' } }), + json: async () => ({ error: { error_code: 10, error_msg: 'Internal server error' } }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: { success: 1 } }), + json: async () => ({ response: { success: 1 } }), + }; + }); + + const auth = createServiceAuth(serviceToken); + const res = await client.call<{ success: number }>( + 'wall.getById', + {}, + auth, + { maxRetries: 3, retryInitialDelayMs: 5 } + ); + + expect(res.success).toBe(1); + expect(callCount).toBe(3); + }); + + it('does NOT retry on non-retryable auth error (error_code 5)', async () => { + let callCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + callCount++; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ error: { error_code: 5, error_msg: 'User auth failed' } }), + json: async () => ({ error: { error_code: 5, error_msg: 'User auth failed' } }), + }; + }); + + const auth = createServiceAuth(serviceToken); + await expect( + client.call('wall.getById', {}, auth, { maxRetries: 3 }) + ).rejects.toThrow(VkAuthError); + + expect(callCount).toBe(1); + }); + + it('does NOT retry on permission error (error_code 7)', async () => { + mockFetchVkError(7, 'Permission to perform this action is denied'); + + const auth = createServiceAuth(serviceToken); + await expect( + client.call('wall.getById', {}, auth, { maxRetries: 2 }) + ).rejects.toThrow(VkPermissionError); + }); + + it('throws VkValidationError on malformed non-JSON response', async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + text: async () => '502 Bad Gateway', + json: async () => { throw new Error('invalid json'); }, + }); + + const auth = createServiceAuth(serviceToken); + await expect( + client.call('wall.getById', {}, auth, { maxRetries: 0 }) + ).rejects.toThrow(VkValidationError); + }); + + it('pagination abstraction handles single and multiple pages seamlessly', async () => { + let page = 0; + const fetchPage = vi.fn(async (offset: number, count: number) => { + page++; + if (page === 1) { + return { items: [1, 2, 3], totalCount: 6 }; + } + if (page === 2) { + return { items: [4, 5, 6], totalCount: 6 }; + } + return { items: [], totalCount: 6 }; + }); + + const items = await fetchPaginatedVk({ + pageSize: 3, + fetchPage, + }); + + expect(items).toEqual([1, 2, 3, 4, 5, 6]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it('VkProvider deduplicates LIKE + COMMENT into a single merged participant', async () => { + // 1. Likes response: user 100 + // 2. Comments response: user 100 and user 200 + (global.fetch as ReturnType).mockImplementation(async (url: string) => { + if (url.includes('likes.getList')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + response: { + count: 1, + items: [{ id: 100, first_name: 'Alice', last_name: 'Like', photo_100: 'http://a.jpg' }], + }, + }), + }; + } + if (url.includes('wall.getComments')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + response: { + count: 2, + items: [ + { id: 1, from_id: 100, text: 'Comment from Alice' }, + { id: 2, from_id: 200, text: 'Comment from Bob' }, + ], + profiles: [ + { id: 100, first_name: 'Alice', last_name: 'Like' }, + { id: 200, first_name: 'Bob', last_name: 'Comment' }, + ], + }, + }), + }; + } + return { ok: false, status: 404, text: async () => '' }; + }); + + const provider = new VkProvider(serviceToken, client); + const participants = await provider.fetchParticipants({ + ownerId: '-100', + postId: '1', + includeLikes: true, + includeComments: true, + }); + + expect(participants.length).toBe(2); + + const alice = participants.find(p => p.platformUserId === '100'); + expect(alice).toBeDefined(); + expect(alice?.liked).toBe(true); + expect(alice?.commented).toBe(true); + expect(alice?.commentsCount).toBe(1); + + const bob = participants.find(p => p.platformUserId === '200'); + expect(bob).toBeDefined(); + expect(bob?.liked).toBe(false); + expect(bob?.commented).toBe(true); + }); + + it('VkProvider batches subscription checks into 500-user chunks', async () => { + const userIds = Array.from({ length: 1200 }, (_, i) => String(1000 + i)); + const capturedChunks: string[] = []; + + (global.fetch as ReturnType).mockImplementation(async (url: string, init: any) => { + const body = new URLSearchParams(init?.body || ''); + const idsParam = body.get('user_ids') || ''; + capturedChunks.push(idsParam); + + const parsedIds = idsParam.split(',').map(Number); + const items = parsedIds.map(id => ({ user_id: id, member: id % 2 === 0 ? 1 : 0 })); + + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: items }), + }; + }); + + const provider = new VkProvider(serviceToken, client); + const subMap = await provider.checkSubscription(userIds, '100'); + + expect(capturedChunks.length).toBe(3); // 500, 500, 200 + expect(capturedChunks[0].split(',').length).toBe(500); + expect(capturedChunks[1].split(',').length).toBe(500); + expect(capturedChunks[2].split(',').length).toBe(200); + + expect(subMap.size).toBe(1200); + expect(subMap.get('1000')).toBe(true); + expect(subMap.get('1001')).toBe(false); + }); + + it('ensures token is never exposed in logs or redaction utility', () => { + const secret = 'vk1.a.secret_top_secret_token_value_99999'; + const redacted = redactToken(secret); + + expect(redacted).not.toBe(secret); + expect(redacted).toContain('...'); + expect(redacted.length).toBeLessThan(15); + }); +}); diff --git a/tests/vk-errors.test.ts b/tests/vk-errors.test.ts index 89fab32..8fde647 100644 --- a/tests/vk-errors.test.ts +++ b/tests/vk-errors.test.ts @@ -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).toHaveBeenCalledTimes(1); - }); });