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:
parent
a27341a302
commit
e666161612
12 changed files with 1291 additions and 164 deletions
28
docs/VK_AUTH_MODEL.md
Normal file
28
docs/VK_AUTH_MODEL.md
Normal 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
55
docs/VK_CLIENT.md
Normal 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
25
docs/VK_ERROR_MODEL.md
Normal 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.
|
||||||
41
src/integrations/vk/vk-auth.ts
Normal file
41
src/integrations/vk/vk-auth.ts
Normal 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 };
|
||||||
|
}
|
||||||
227
src/integrations/vk/vk-client.ts
Normal file
227
src/integrations/vk/vk-client.ts
Normal 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();
|
||||||
158
src/integrations/vk/vk-errors.ts
Normal file
158
src/integrations/vk/vk-errors.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/integrations/vk/vk-rate-limit.ts
Normal file
65
src/integrations/vk/vk-rate-limit.ts
Normal 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,
|
||||||
|
});
|
||||||
91
src/integrations/vk/vk-retry.ts
Normal file
91
src/integrations/vk/vk-retry.ts
Normal 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;
|
||||||
|
}
|
||||||
121
src/integrations/vk/vk-types.ts
Normal file
121
src/integrations/vk/vk-types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,18 @@ import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||||
import { RawParticipant } from '../../core/types/participant';
|
import { RawParticipant } from '../../core/types/participant';
|
||||||
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
||||||
import { parseVkPostUrl } from './vk-parser';
|
import { parseVkPostUrl } from './vk-parser';
|
||||||
|
import { IVkClient, defaultVkClient, fetchPaginatedVk } from '@/integrations/vk/vk-client';
|
||||||
interface VkApiResponse<T> {
|
import { VkAuthContext } from '@/integrations/vk/vk-types';
|
||||||
response?: T;
|
import { createServiceAuth } from '@/integrations/vk/vk-auth';
|
||||||
error?: {
|
import {
|
||||||
error_code: number;
|
VkWallPost,
|
||||||
error_msg: string;
|
VkUserProfile,
|
||||||
};
|
VkGroupProfile,
|
||||||
}
|
VkLikesGetListResponse,
|
||||||
|
VkWallGetCommentsResponse,
|
||||||
|
VkIsMemberItem
|
||||||
|
} from '@/integrations/vk/vk-types';
|
||||||
|
import { VkNotFoundError, VkAuthError } from '@/integrations/vk/vk-errors';
|
||||||
|
|
||||||
export class VkProvider implements SocialMediaProvider {
|
export class VkProvider implements SocialMediaProvider {
|
||||||
readonly platform: PlatformType = 'VK';
|
readonly platform: PlatformType = 'VK';
|
||||||
|
|
@ -23,82 +27,64 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
adminDetectionNote: 'Требует расширенных прав администратора группы',
|
adminDetectionNote: 'Требует расширенных прав администратора группы',
|
||||||
};
|
};
|
||||||
|
|
||||||
private serviceToken?: string;
|
private readonly client: IVkClient;
|
||||||
private apiVersion = '5.199';
|
private readonly authContext: VkAuthContext;
|
||||||
private baseUrl = 'https://api.vk.com/method';
|
|
||||||
|
|
||||||
constructor(serviceToken?: string) {
|
constructor(serviceToken?: string, client?: IVkClient) {
|
||||||
this.serviceToken = serviceToken || process.env.VK_SERVICE_TOKEN;
|
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);
|
return parseVkPostUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callApi<T>(method: string, params: Record<string, string | number>): Promise<T> {
|
private ensureConfigured(): void {
|
||||||
if (!this.serviceToken) {
|
if (!this.authContext.token) {
|
||||||
throw new Error('VK_SERVICE_TOKEN is not configured in environment variables');
|
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> {
|
async fetchPost(url: string): Promise<PostMetadata> {
|
||||||
|
this.ensureConfigured();
|
||||||
|
|
||||||
const parsed = this.parsePostUrl(url);
|
const parsed = this.parsePostUrl(url);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
throw new Error('Invalid VK post URL format');
|
throw new VkNotFoundError('Invalid VK post URL format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ownerId, postId } = parsed;
|
const { ownerId, postId } = parsed;
|
||||||
|
|
||||||
const response = await this.callApi<{ items: any[]; profiles?: any[]; groups?: any[] }>(
|
const response = await this.client.call<{
|
||||||
'wall.getById',
|
items: VkWallPost[];
|
||||||
{
|
profiles?: VkUserProfile[];
|
||||||
posts: `${ownerId}_${postId}`,
|
groups?: VkGroupProfile[];
|
||||||
extended: 1,
|
}>('wall.getById', {
|
||||||
}
|
posts: `${ownerId}_${postId}`,
|
||||||
);
|
extended: 1,
|
||||||
|
}, this.authContext);
|
||||||
|
|
||||||
if (!response.items || response.items.length === 0) {
|
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];
|
const post = response.items[0];
|
||||||
|
|
||||||
let authorName = `VK Wall ${ownerId}`;
|
let authorName = `VK Wall ${ownerId}`;
|
||||||
let authorAvatarUrl = undefined;
|
let authorAvatarUrl: string | undefined = undefined;
|
||||||
|
|
||||||
if (ownerId.startsWith('-')) {
|
if (ownerId.startsWith('-')) {
|
||||||
const groupId = Math.abs(parseInt(ownerId, 10));
|
const groupId = Math.abs(parseInt(ownerId, 10));
|
||||||
const group = (response.groups || []).find(g => g.id === groupId);
|
const group = (response.groups || []).find(g => g.id === groupId);
|
||||||
if (group) {
|
if (group) {
|
||||||
authorName = group.name;
|
authorName = group.name;
|
||||||
authorAvatarUrl = group.photo_100 || group.photo_200;
|
authorAvatarUrl = group.photo_50;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const userId = parseInt(ownerId, 10);
|
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) {
|
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) {
|
if (photoAttachment && photoAttachment.photo && photoAttachment.photo.sizes) {
|
||||||
const sizes = photoAttachment.photo.sizes;
|
const sizes = photoAttachment.photo.sizes;
|
||||||
imageUrl = sizes[sizes.length - 1]?.url;
|
imageUrl = sizes[sizes.length - 1]?.url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textContent = String(post.text || '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platform: 'VK',
|
platform: 'VK',
|
||||||
ownerId,
|
ownerId,
|
||||||
postId,
|
postId,
|
||||||
sourceUrl: url,
|
sourceUrl: url,
|
||||||
title: post.text ? post.text.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
|
title: textContent ? textContent.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
|
||||||
text: post.text || '',
|
text: textContent,
|
||||||
authorName,
|
authorName,
|
||||||
authorAvatarUrl,
|
authorAvatarUrl,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
|
@ -136,126 +124,137 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
|
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
|
||||||
|
this.ensureConfigured();
|
||||||
|
|
||||||
const { ownerId, postId } = params;
|
const { ownerId, postId } = params;
|
||||||
const participantsMap = new Map<string, RawParticipant>();
|
const participantsMap = new Map<string, RawParticipant>();
|
||||||
|
|
||||||
// 1. Fetch Likes
|
// 1. Fetch Likes with full pagination
|
||||||
if (params.includeLikes !== false) {
|
if (params.includeLikes !== false) {
|
||||||
let offset = 0;
|
const likeProfiles = await fetchPaginatedVk<VkUserProfile>({
|
||||||
const count = 1000;
|
pageSize: 100,
|
||||||
let totalLikes = 0;
|
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 {
|
return {
|
||||||
const likesRes = await this.callApi<{ count: number; items: any[] }>('likes.getList', {
|
items: (res.items || []) as VkUserProfile[],
|
||||||
type: 'post',
|
totalCount: res.count,
|
||||||
owner_id: ownerId,
|
};
|
||||||
item_id: postId,
|
},
|
||||||
filter: 'likes',
|
onProgress: (loaded, total) => {
|
||||||
extended: 1,
|
if (params.onProgress) {
|
||||||
count,
|
params.onProgress(loaded, total ?? loaded, 'Загрузка лайков...');
|
||||||
offset,
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
if (params.includeComments) {
|
||||||
let offset = 0;
|
await fetchPaginatedVk<{ from_id: number }>({
|
||||||
const count = 100;
|
pageSize: 100,
|
||||||
let totalComments = 0;
|
fetchPage: async (offset, count) => {
|
||||||
|
const res = await this.client.call<VkWallGetCommentsResponse>('wall.getComments', {
|
||||||
do {
|
|
||||||
const commentsRes = await this.callApi<{ count: number; items: any[]; profiles?: any[] }>(
|
|
||||||
'wall.getComments',
|
|
||||||
{
|
|
||||||
owner_id: ownerId,
|
owner_id: ownerId,
|
||||||
post_id: postId,
|
post_id: postId,
|
||||||
extended: 1,
|
extended: 1,
|
||||||
count,
|
count,
|
||||||
offset,
|
offset,
|
||||||
fields: 'photo_100,photo_200,screen_name',
|
fields: 'photo_100,photo_200,screen_name',
|
||||||
}
|
}, this.authContext);
|
||||||
);
|
|
||||||
|
|
||||||
totalComments = commentsRes.count;
|
const profileMap = new Map<number, VkUserProfile>(
|
||||||
const profileMap = new Map<number, any>(
|
(res.profiles || []).map(p => [p.id, p])
|
||||||
(commentsRes.profiles || []).map(p => [p.id, p])
|
);
|
||||||
);
|
|
||||||
|
|
||||||
for (const item of commentsRes.items) {
|
for (const item of res.items || []) {
|
||||||
if (item.from_id && item.from_id > 0) {
|
if (item.from_id && item.from_id > 0) {
|
||||||
const userId = String(item.from_id);
|
const userId = String(item.from_id);
|
||||||
const prof = profileMap.get(item.from_id);
|
const prof = profileMap.get(item.from_id);
|
||||||
const existing = participantsMap.get(userId);
|
const existing = participantsMap.get(userId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.commented = true;
|
existing.commented = true;
|
||||||
existing.commentsCount = (existing.commentsCount || 0) + 1;
|
existing.commentsCount = (existing.commentsCount || 0) + 1;
|
||||||
} else {
|
} else {
|
||||||
participantsMap.set(userId, {
|
participantsMap.set(userId, {
|
||||||
platformUserId: userId,
|
platformUserId: userId,
|
||||||
firstName: prof?.first_name || 'Участник',
|
firstName: prof?.first_name || 'Участник',
|
||||||
lastName: prof?.last_name || userId,
|
lastName: prof?.last_name || userId,
|
||||||
username: prof?.screen_name,
|
username: prof?.screen_name,
|
||||||
avatarUrl: prof?.photo_100,
|
avatarUrl: prof?.photo_100,
|
||||||
source: 'COMMENTS',
|
source: 'COMMENTS',
|
||||||
liked: false,
|
liked: false,
|
||||||
commented: true,
|
commented: true,
|
||||||
commentsCount: 1,
|
commentsCount: 1,
|
||||||
reposted: false,
|
reposted: false,
|
||||||
subscribed: false,
|
subscribed: false,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
offset += count;
|
return {
|
||||||
} while (offset < totalComments && offset < 1000);
|
items: res.items || [],
|
||||||
|
totalCount: res.count,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onProgress: () => {
|
||||||
|
if (params.onProgress) {
|
||||||
|
params.onProgress(participantsMap.size, participantsMap.size, 'Загрузка комментариев...');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(participantsMap.values());
|
return Array.from(participantsMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
|
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
|
||||||
|
this.ensureConfigured();
|
||||||
|
|
||||||
const cleanGroupId = groupId.replace(/^-/, '');
|
const cleanGroupId = groupId.replace(/^-/, '');
|
||||||
const resultMap = new Map<string, boolean>();
|
const resultMap = new Map<string, boolean>();
|
||||||
|
|
||||||
|
// VK API groups.isMember allows up to 500 user_ids per batch call
|
||||||
const chunkSize = 500;
|
const chunkSize = 500;
|
||||||
for (let i = 0; i < userIds.length; i += chunkSize) {
|
for (let i = 0; i < userIds.length; i += chunkSize) {
|
||||||
const chunk = userIds.slice(i, 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',
|
'groups.isMember',
|
||||||
{
|
{
|
||||||
group_id: cleanGroupId,
|
group_id: cleanGroupId,
|
||||||
user_ids: chunk.join(','),
|
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);
|
resultMap.set(String(item.user_id), item.member === 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
312
tests/vk-client-integration.test.ts
Normal file
312
tests/vk-client-integration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { VkProvider } from '../src/providers/vk/vk-provider';
|
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', () => {
|
describe('VK API error handling', () => {
|
||||||
const token = 'vk1.a.test-service-token';
|
const token = 'vk1.a.test-service-token';
|
||||||
|
|
@ -18,6 +28,7 @@ describe('VK API error handling', () => {
|
||||||
status,
|
status,
|
||||||
statusText: status === 500 ? 'Internal Server Error' : 'OK',
|
statusText: status === 500 ? 'Internal Server Error' : 'OK',
|
||||||
json: async () => json,
|
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' } });
|
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on expired token (error_code 5)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on access denied (error_code 15)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } });
|
mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on rate limit per second (error_code 6)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
|
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 () => {
|
it('throws on daily rate limit (error_code 29)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } });
|
mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on private profile (error_code 30)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } });
|
mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on deleted or banned user (error_code 18)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } });
|
mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
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' } });
|
mockFetchJson({ error: { error_code: 100, error_msg: 'One of the parameters specified was missing or invalid' } });
|
||||||
const provider = new VkProvider(token);
|
const provider = new VkProvider(token);
|
||||||
|
|
||||||
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 () => {
|
it('throws when post is not found in response', async () => {
|
||||||
mockFetchJson({ response: { items: [] } });
|
mockFetchJson({ response: { items: [] } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on unavailable community (error_code 203)', async () => {
|
||||||
mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } });
|
mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } });
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on empty response body', async () => {
|
||||||
mockFetchJson({});
|
mockFetchJson({});
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on HTTP 500 from VK', async () => {
|
||||||
mockFetchJson({}, 500);
|
mockFetchJson('Internal Server Error', 500);
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('throws on network timeout / failure', async () => {
|
||||||
mockFetchNetworkError('fetch failed');
|
mockFetchNetworkError('fetch failed');
|
||||||
const provider = new VkProvider(token);
|
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 () => {
|
it('does not leak the service token in thrown error messages', async () => {
|
||||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||||
const provider = new VkProvider(token);
|
const provider = new VkProvider(token);
|
||||||
|
|
||||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
|
|
||||||
try {
|
try {
|
||||||
await provider.fetchPost('https://vk.com/wall-1_1');
|
await provider.fetchPost('https://vk.com/wall-1_1');
|
||||||
|
expect.unreachable('Should have thrown');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
expect(err.message).not.toContain(token);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue