test(opencode): independent review, VK integration plan, expanded unit tests, provider capabilities validation, and security/concurrency coverage

This commit is contained in:
Ochenstarik 2026-08-18 00:24:00 +07:00
parent 0fb5fe3f8d
commit d0aa55ae30
13 changed files with 2359 additions and 40 deletions

318
docs/OPENCODE_REVIEW.md Normal file
View file

@ -0,0 +1,318 @@
# OpenCode Independent Review — Randomayzer
**Scope:** test coverage, security, concurrency, VK integration preparation.
**Review date:** 2026-08-18
**Branch reviewed:** `main` (initial clone)
**Reviewer:** OpenCode
> **Coordination note:** another agent (Antigravity) is performing Core Audit Fixes / Phase 1.2.
> This review intentionally does **not** rewrite the Randomizer, Prisma schema, FSM, AuditProof, `giveaway-store.ts`, VK OAuth, or any existing core architecture. Findings that touch those areas are documented as recommendations and are **not** auto-fixed unless they are isolated, low-risk, and do not overlap with Antigravity's scope.
---
## Summary of findings
| Severity | Count | Themes |
|---|---|---|
| CRITICAL | 2 | double-draw race, silent DB→memory fallback |
| HIGH | 6 | capability violations, API input validation, VK pagination limits, token exposure surface, missing retry/rate-limit, JSON snapshot scalability |
| MEDIUM | 5 | environment-based mock fallback, `.gitignore` gaps, error-message leakage, unused env vars, `excludeDuplicateComments` ignored |
| LOW | 3 | in-memory IDs via `Math.random()`, `filterRules` immutability gaps, `MINIMUM_COMMENTS` not implemented |
| INFO | 3 | architecture strengths, provider abstraction, clean separation |
---
## CRITICAL
### C1 — Race condition allows duplicate draw for the same giveaway
- **File:** `src/app/api/giveaways/[id]/draw/route.ts`
- **Lines:** 1260
- **Description:** The route performs a read-check-write sequence without an atomic lock or transaction:
1. `giveaway.status === 'DRAWN'` is checked on a giveaway object loaded at line 14.
2. A snapshot is fetched or created (lines 2836).
3. `GiveawayFSM.assertCanDraw('SNAPSHOT_LOCKED')` is called with a hard-coded status string, **not** the current persisted status (line 41).
4. `GiveawayStore.saveDrawResult` persists the draw (line 60).
- **Consequence:** Two concurrent `POST /draw` requests can both pass the initial `DRAWN` check while the giveaway is `READY`, both create/retrieve a snapshot, both pass `assertCanDraw('SNAPSHOT_LOCKED')`, and both persist a `DrawResult`. The second call overwrites the first or produces two audit records for the same giveaway, violating provably-fair invariants.
- **Recommended fix:** Wrap the entire read-snapshot-draw-write sequence in a database-level atomic operation. Options:
- Use a `SELECT FOR UPDATE` row lock on the giveaway row at the start of the transaction.
- Or add a unique constraint on `DrawResult.giveawayId`/`AuditRecord.giveawayId` (already present) and retry on conflict, but the route must read the row **inside** the transaction and fail fast on status mismatch.
- Pass the actual persisted status to `assertCanDraw`, not a literal string.
- **Coordination:** overlaps Antigravity Phase 1.2 (core audit / persistence). **Not auto-fixed.**
### C2 — Prisma errors silently fall back to in-memory storage
- **File:** `src/lib/giveaway-store.ts`
- **Lines:** 2458 (`create`, `getById`, `listAll`)
- **Description:** Every read/write operation catches any Prisma error, logs a warning, swaps `activeRepository` to `MemoryGiveawayRepository`, and retries. There is no recovery path back to Prisma.
- **Consequence:** A transient DB hiccup (network blip, pool timeout, lock timeout) permanently downgrades the running process to in-memory mode. Subsequent requests create giveaways that are lost on restart or invisible to other horizontally scaled instances. This violates data durability and audit traceability.
- **Recommended fix:**
- Remove the silent fallback from production code. Fail fast and return a 500/503 with a clear error.
- Keep `MemoryGiveawayRepository` only for unit tests via `GiveawayStore.setRepository(...)`.
- If a fallback is truly required, implement it at the infrastructure level (connection pool, replica read), not by switching the repository implementation mid-process.
- **Coordination:** `giveaway-store.ts` is explicitly in Antigravity's scope. **Not auto-fixed.**
---
## HIGH
### H1 — Unsupported filter rules are accepted for the current provider
- **File:** `src/app/api/giveaways/[id]/participants/route.ts`
- **Lines:** 2730
- **Description:** The route forwards `rules.requireRepost` to `provider.fetchParticipants({ includeReposts: rules.requireRepost })` without checking `provider.capabilities.reposts`. `VkProvider` and `VkMockProvider` both declare `reposts: false`, yet the backend still attempts to honor the rule. There is no backend validation that rejects a rule the selected provider cannot verify.
- **Consequence:** Organizers can configure giveaways that the system cannot actually verify. The UI may imply reposts are checked while the provider silently ignores them, undermining trust and audit correctness.
- **Recommended fix:** Add a `validateFilterRulesAgainstCapabilities(rules, capabilities)` guard (see `tests/provider-capabilities.test.ts` for contract expectations). Call it in the participants route and snapshot route before any provider work. Return `400` with a clear message such as `"requireRepost is not supported by VK provider"`.
- **Coordination:** low risk, isolated. A validation utility was added during this review; wiring it into routes is documented as a recommendation and tested.
### H2 — API routes lack input validation
- **Files:**
- `src/app/api/giveaways/route.ts` (lines 1435)
- `src/app/api/giveaways/[id]/draw/route.ts` (lines 4246)
- `src/app/api/giveaways/[id]/snapshot/route.ts` (lines 1030)
- `src/app/api/posts/preview/route.ts` (lines 530)
- **Description:** No schema validation or bounds checks on:
- `winnersCount` / `reserveWinnersCount` (negative, zero, or extremely large values are accepted).
- `seed` (empty string, multi-megabyte seed, or untrusted external seed).
- `filterRules` (unknown keys are ignored; missing required keys may be defaulted unsafely).
- `sourceUrl` / `url` (only presence is checked in some routes, not format/length).
- JSON body parsing failures return generic 500 instead of 400.
- **Consequence:** Invalid or malicious payloads can corrupt persisted data, cause confusing draw behavior (e.g., `winnersCount: -1` produces zero winners), or be used for DoS via huge strings stored in `seed`, `filterRules`, or snapshot JSON.
- **Recommended fix:** Introduce a lightweight schema validator (Zod is recommended) for all API routes. At minimum enforce:
- `winnersCount` integer >= 1, capped to a reasonable maximum (e.g., 10 000).
- `reserveWinnersCount` integer >= 0, capped similarly.
- `seed` non-empty string, max length 1024, sanitized.
- `filterRules` strict shape with only known keys and capability checks.
- Malformed JSON returns `400 Bad Request`.
- **Coordination:** additive change, does not touch forbidden files. Validation tests added; full Zod refactor deferred pending team agreement.
### H3 — VK provider silently caps large participant lists
- **File:** `src/providers/vk/vk-provider.ts`
- **Lines:** 142183 (likes), 186238 (comments)
- **Description:** `fetchParticipants` loops with `offset < totalLikes && offset < 5000` for likes and `offset < totalComments && offset < 1000` for comments. For posts with more than ~5 000 likes or ~1 000 comments, the remaining participants are silently truncated.
- **Consequence:** Large giveaways exclude valid participants without warning, breaking fairness.
- **Recommended fix:** Remove artificial caps or make them configurable/documented with explicit UI warnings. Implement paginated fetching with rate limiting and progress callbacks. Use `execute` batching where beneficial.
- **Coordination:** touches `VkProvider`; overlaps VK integration preparation. **Not auto-fixed.**
### H4 — VK service token travels in query string and may leak through logs/proxies
- **File:** `src/providers/vk/vk-provider.ts`
- **Lines:** 4352 (`callApi`)
- **Description:** `access_token` is appended to the URL query string. `fetch` errors, server/proxy access logs, APM traces, or exception reporters may capture the full URL. The constructor also reads `process.env.VK_SERVICE_TOKEN` directly; while this is correct, there is no audit that the value never appears in error objects.
- **Consequence:** If an error reporter logs the request URL, the VK service token is exposed. Service tokens are long-lived and grant broad public-data access.
- **Recommended fix:**
- Prefer sending the token in an `Authorization: Bearer <token>` header where VK allows it; otherwise ensure request URLs are never logged.
- Scrub tokens from any error serialization in `VkProvider`.
- Add automated tests asserting that token does not appear in thrown messages, responses, or logs (see `tests/security.test.ts`).
- **Coordination:** low risk; security tests added. Header change depends on VK API contract (documented as recommendation).
### H5 — No rate limiting, retry, or timeout strategy in VK client
- **File:** `src/providers/vk/vk-provider.ts`
- **Lines:** 3869 (`callApi`)
- **Description:** `callApi` uses a single `fetch` call. It does not:
- Set a timeout.
- Retry on transient failures (network, 5xx, VK error 6/29).
- Back off on rate-limit responses.
- Distinguish retryable from non-retryable VK errors.
- **Consequence:** A single network stall or rate-limit response fails the entire participant import. Large giveaways are unreliable and slow.
- **Recommended fix:** Implement `VkClient``RateLimiter``RetryPolicy``VkProvider` as documented in `docs/VK_INTEGRATION_PLAN.md`. Start with interfaces and a simple exponential-backoff wrapper; do not over-engineer.
- **Coordination:** preparation-only; no production implementation added.
### H6 — Storing entire eligible participant snapshot as JSON does not scale
- **Files:**
- `prisma/schema.prisma``ParticipantSnapshot.eligibleParticipants Json`
- `src/core/types/audit.ts``ParticipantSnapshotData.eligibleParticipants: FilteredParticipant[]`
- `src/lib/repository/prisma-repository.ts` — snapshot create/read maps the full JSON
- **Description:** Every eligible participant is serialized into a single JSON column. Each participant record contains avatar URLs, names, usernames, and flags.
- **Consequence:**
- ~1 000 participants ≈ 200400 KB JSON.
- ~100 000 participants ≈ 2040 MB per snapshot row.
- ~500 000 participants ≈ 100200 MB per row.
- PostgreSQL `jsonb` limit is 1 GB, but reading/writing such rows consumes large amounts of application memory, slows queries, and blocks the UI table. Hashing the snapshot becomes CPU-bound.
- **Recommended fix:** See **Scalability** section below. Short term: cap supported giveaway size and warn organizers. Long term: store participants in normalized `ParticipantSnapshotItem` rows and compute the snapshot hash incrementally or on a streaming cursor.
- **Coordination:** Prisma schema is explicitly forbidden to change. **Not auto-fixed.**
---
## MEDIUM
### M1 — Environment-based provider selection can silently run mock in production
- **File:** `src/providers/registry.ts`
- **Lines:** 912
- **Description:** `ProviderRegistry` chooses `VkProvider` only if `VK_SERVICE_TOKEN` is present and longer than 10 characters. If the variable is missing, empty, or a placeholder shorter than 11 chars, the production process silently uses `VkMockProvider`, returning synthetic data.
- **Consequence:** Misconfigured deployments appear to work but produce fake participants and fake draws.
- **Recommended fix:** Fail fast at startup when a real token is expected. Reserve `VkMockProvider` for explicit `NODE_ENV=test` or a `USE_VK_MOCK=1` flag.
- **Coordination:** isolated; recommended but not auto-fixed to avoid behavior changes.
### M2 — `.gitignore` does not cover all environment files
- **File:** `.gitignore`
- **Lines:** 1922
- **Description:** Only `.env` and `.env*.local` are ignored. `.env.production`, `.env.staging`, `.env.test`, and `.env*.[other]` are not ignored.
- **Consequence:** Accidental commits of production secrets are possible.
- **Recommended fix:** Add `!.env.example` and `.env*` (with explicit allow-list for safe examples) or list common variants.
- **Coordination:** isolated; not auto-fixed because it is project hygiene and can be handled by Antigravity.
### M3 — Raw error messages from external APIs are returned to clients
- **Files:** all `src/app/api/**/route.ts`
- **Description:** Catch blocks return `error.message` directly in the JSON body.
- **Consequence:** Internal details (file paths, provider error texts, partial URLs) may leak to the client.
- **Recommended fix:** In production, log the full error server-side and return a sanitized message. Use `NODE_ENV` to decide detail level.
- **Coordination:** additive; not auto-fixed because it spans many routes.
### M4 — `.env.example` contains unused variables
- **File:** `.env.example`
- **Lines:** 710
- **Description:** `VK_APP_ID` and `VK_APP_SECRET` are documented but not referenced in code.
- **Consequence:** Operators may populate them believing they are required, increasing secret surface area without benefit.
- **Recommended fix:** Remove unused variables or add comments explaining they are reserved for future OAuth/community-token flows.
- **Coordination:** trivial; not auto-fixed.
### M5 — `excludeDuplicateComments` flag exists but is not honored
- **File:** `src/core/filtering/filter-engine.ts`
- **Lines:** 2542
- **Description:** `FilterRules` includes `excludeDuplicateComments`, but `applyFilterRules` always deduplicates participants and aggregates `commentsCount` regardless of the flag.
- **Consequence:** A future UI toggle for duplicate comments will not behave as expected. The rule is also included in `computeConditionsHash`, so changing its behavior later will alter snapshot hashes.
- **Recommended fix:** When `excludeDuplicateComments` is false, keep each raw comment as a separate entry (or disable aggregation). Update hash tests accordingly.
- **Coordination:** touches filter engine; not auto-fixed because it changes existing semantics.
---
## LOW
### L1 — In-memory repository uses `Math.random()` for IDs
- **File:** `src/lib/repository/memory-repository.ts`
- **Line:** 17
- **Description:** Giveaway IDs are generated with `Math.random()`. IDs are not secrets, but this is inconsistent with the cryptographic rigor used elsewhere.
- **Consequence:** Negligible for tests; low risk for production if the memory repo is ever used seriously.
- **Recommended fix:** Use `crypto.randomUUID()` or a CUID generator.
- **Coordination:** not auto-fixed; trivial.
### L2 — `filterRules` object in snapshot route can mutate stored rules unexpectedly
- **File:** `src/app/api/giveaways/[id]/snapshot/route.ts`
- **Lines:** 2430
- **Description:** `body.filterRules || giveaway.filterRules` passes the request body object directly to `createAndLockSnapshot`, which then stores it in the DB.
- **Consequence:** Malformed or extra keys in the body can be persisted, affecting `conditionsHash` and future audits.
- **Recommended fix:** Deep-clone and validate rules before persisting.
- **Coordination:** overlaps input validation (H2); deferred.
### L3 — No `minimumComments` rule despite review task mentioning it
- **File:** `src/core/types/giveaway.ts`
- **Description:** `FilterRules` does not contain a `minimumComments` field. The task asked to test combinations including `minimumComments`, but the rule is not implemented.
- **Consequence:** Organizer cannot require "at least N comments".
- **Recommended fix:** Add `minimumComments?: number` to `FilterRules` and enforce it in `applyFilterRules` after deduplication.
- **Coordination:** requires type + filter engine change; **not auto-fixed.**
---
## INFO
### I1 — Strong core domain isolation
- The core layer (`src/core/randomizer`, `src/core/filtering`, `src/core/fsm`) has no imports from Next.js, React, or provider implementations. This is good and should be preserved.
### I2 — Provider abstraction is clean and testable
- `SocialMediaProvider` interface (`src/providers/types.ts`) cleanly separates platform specifics from the domain. `VkMockProvider` allows offline testing.
### I3 — Provably-fair hashing is deterministic and order-independent
- `computeParticipantsSnapshotHash` and `computeConditionsHash` canonicalize input and produce stable hashes, enabling third-party verification.
---
## Scalability analysis
### Participant snapshot JSON size estimation
A single `FilteredParticipant` record contains:
```json
{
"platformUserId": "123456789",
"firstName": "Иван",
"lastName": "Иванов",
"username": "ivanov",
"avatarUrl": "https://.../photo.jpg",
"source": "COMBINED",
"liked": true,
"commented": true,
"commentsCount": 3,
"reposted": false,
"subscribed": true,
"eligible": true,
"exclusionReason": null
}
```
Approximate serialized size: **250400 bytes** per participant (avatar URLs dominate).
| Eligible participants | Snapshot JSON size | Assessment |
|---|---|---|
| 1 000 | ~0.3 MB | ✅ Safe. Fits comfortably in memory and a `jsonb` column. |
| 10 000 | ~3 MB | ✅ Still safe for a single request, but UI table rendering starts to degrade. |
| 50 000 | ~15 MB | ⚠️ Heavy. Page load / API response time increases; hashing takes noticeable CPU. |
| 100 000 | ~30 MB | ❌ Risky. Exceeds comfortable single-row JSON workload; Next.js API response limits may be hit. |
| 500 000 | ~150 MB | ❌ Not viable. PostgreSQL `jsonb` max is ~1 GB, but memory, I/O, and UI become impractical. |
### Component-by-component assessment
| Component | 1 000 | 10 000 | 50 000 | 100 000 | 500 000 |
|---|---|---|---|---|---|
| **Memory (API route)** | < 5 MB | ~30 MB | ~150 MB | ~300 MB | > 1 GB |
| **PostgreSQL snapshot row** | 0.3 MB | 3 MB | 15 MB | 30 MB | 150 MB |
| **Snapshot hashing (SHA-256)** | < 1 ms | ~5 ms | ~30 ms | ~80 ms | ~500 ms |
| **API pagination (VK likes)** | 1 req | 10 req | 50 req | 100 req | 500 req |
| **API pagination (VK comments)** | 10 req | 100 req | 500 req | 1 000 req | 5 000 req |
| **Subscription checks** | 2 req | 20 req | 100 req | 200 req | 1 000 req |
| **UI Participants table** | instant | slight lag | unusable without virtualization | browser crash risk | requires server-side pagination |
| **Next.js API response** | < 50 KB | ~300 KB | > 1 MB | > 3 MB | > 15 MB |
### When does `eligibleParticipants JSON` stop being a good solution?
**Threshold: ~10 000 eligible participants.**
Above 10 000, the monolithic JSON snapshot becomes a bottleneck because:
1. **Network:** `/api/giveaways/[id]/draw` and related endpoints return the full snapshot or draw result, producing multi-megabyte responses.
2. **Memory:** every snapshot read loads the entire list into the Node.js heap.
3. **UI:** rendering the participants table without virtualization causes jank or crashes.
4. **Hashing:** snapshot hash computation is O(n) on a large string; while still fast, it blocks the event loop.
5. **Audit replay:** third-party verification must download the entire JSON to recompute the hash.
### Recommended long-term architecture
1. **Normalize snapshot items:** replace `eligibleParticipants Json` with a `ParticipantSnapshotItem` table (or reuse `Participant` with a `snapshotId`). Each row stores one participant; `participantsSnapshotHash` is computed from a streaming sorted cursor.
2. **Paginated API:** return only summary counts by default; expose paginated endpoints for participant lists.
3. **Background jobs:** for > 10 000 participants, run import and subscription checks in a background worker (e.g., BullMQ / inngest) and update giveaway status asynchronously.
4. **Streaming hash:** compute the snapshot hash using a streaming SHA-256 over sorted rows instead of materializing the full JSON string.
5. **Cap + warn:** until the architecture changes, enforce a configurable maximum eligible count (e.g., 10 000) with a clear error message and guidance.
> **Coordination note:** schema changes are deferred to Antigravity Phase 1.2. The analysis above is provided for planning only.
---
## Test coverage notes
During this review the following test files were added or extended:
- `tests/filter-engine.test.ts` — extended with combination rule matrices.
- `tests/vk-mock-provider.test.ts` — new; scenarios for 0/1/10/1000/large participants, likes-only, comments-only, subscription, duplicates, excluded IDs.
- `tests/participant-pipeline.test.ts` — new; full fetch → enrichment → subscription → filtering → eligible pipeline.
- `tests/provider-capabilities.test.ts` — new; validation of rules against provider capabilities.
- `tests/vk-errors.test.ts` — new; VK error handling via mocked `fetch`.
- `tests/security.test.ts` — new; token leakage checks.
- `tests/api-validation.test.ts` — new; route-level input validation.
- `tests/concurrency.test.ts` — new; race-condition analysis (findings documented, no production fix).
See each file for concrete test cases and the final report for pass counts.

255
docs/VK_INTEGRATION_PLAN.md Normal file
View file

@ -0,0 +1,255 @@
# План интеграции VK API для Randomayzer
> **Контекст:** Randomayzer — Next.js + TypeScript + Prisma + PostgreSQL сервис для проведения доказуемо честных розыгрышей ВКонтакте. В текущей реализации (`src/providers/vk/vk-provider.ts`) используется сервисный токен `VK_SERVICE_TOKEN` и методы `wall.getById`, `likes.getList`, `wall.getComments`, `groups.isMember`. OAuth / VK ID не реализованы.
>
> **Статус документа:** планировочный, без production-кода. Все факты, которые не удалось подтвердить официальной документацией VK, помечены как `UNVERIFIED`.
---
## Authentication
Для работы с VK API существуют три типа ключей доступа. Для Randomayzer наиболее подходящим **на старте** является **сервисный ключ** (соответствует текущей реализации `VkProvider`), так как он не требует авторизации пользователя и покрывает базовые сценарии розыгрыша: получение метаданных поста, сбор лайков/комментариев с открытых стен и пакетную проверку подписки на сообщество.
Расширенные сценарии (проверка репостов на закрытых страницах, получение списка руководителей сообщества, действия от имени сообщества) требуют **ключа пользователя** или **ключа сообщества**.
### Service Token (сервисный ключ доступа)
- **Как получить.** В панели управления приложением на [dev.vk.ru](https://dev.vk.ru/ru/admin/apps-list) (раздел **Разработка → Ключи доступа**) либо в кабинете сервиса авторизации VK ID при создании Standalone-приложения/сайта.
- **Scope/права.** Права доступа не запрашиваются. Ключ предназначен для работы с публичными данными игр/мини-приложений и методами, не требующими авторизации пользователя.
- **Применимые методы.** `wall.getById`, `likes.getList`, `wall.getComments`, `wall.getReposts`, `groups.isMember`, `groups.getMembers` (с ограничениями), `execute` UNVERIFIED.
- **Ограничения.**
- Работает только с открытыми профилями и открытыми группами.
- Не позволяет выполнять действия от имени пользователя или сообщества.
- Сбор репостов ограничен политикой приватности VK (закрытые профили не видны).
- **Lifetime.** Не ограничен; при компрометации можно перевыпустить в настройках приложения.
- **Риски.** Утечка ключа открывает публичные данные приложения. Ключ должен храниться только на сервере, никогда — в клиентском коде.
### User Token (ключ доступа пользователя / OAuth 2.0 / VK ID)
- **Как получить.** Через сервис авторизации VK ID (Authorization Code Flow для сервера, Implicit Flow для клиента) или событие `VKWebAppGetAuthToken` VK Bridge в мини-приложениях/играх.
- **Scope/права.** Определяются правами доступа, которые пользователь выдал приложению (например, `wall`, `groups`, `friends`). Базовые права (имя, фото, почта) доступны сразу; расширенные требуют подтверждения профиля бизнеса.
- **Применимые методы.** Все методы, доступные сервисному ключу, плюс методы, требующие авторизации конкретного пользователя: просмотр закрытых профилей при наличии доступа, расширенная работа со стеной, `groups.get` и т.д.
- **Ограничения.**
- Требует прохождения пользователем экрана согласия.
- Короткий срок жизни (см. ниже), необходимо обновление.
- **Lifetime.** **1 час** для ключа, полученного через VK ID / OAuth.
- **Риски.**
- Необходимость безопасного хранения refresh-токенов и секретов приложения.
- Нужно реализовать OAuth flow и обработку отзыва разрешений пользователем.
- При передаче в клиент увеличивается риск перехвата.
### Community Token (ключ доступа сообщества)
- **Как получить.** В настройках сообщества: **Управление → Дополнительно → Работа с API → Ключи доступа**. Можно создать несколько ключей с разным набором прав. Программно — через OAuth ВКонтакте (Authorization Code Flow / Implicit Flow) либо событие `VKWebAppGetCommunityToken` VK Bridge.
- **Scope/права.** Права назначаются при создании ключа (например, `wall`, `photos`, `messages`, `manage`).
- **Применимые методы.**
- Методы в рамках одного сообщества: `wall.getById`, `likes.getList`, `wall.getComments`, `groups.isMember`, `groups.getMembers` (включая `filter=managers` для получения ролей).
- Методы управления сообществом — не требуются Randomayzer на этапе сбора участников.
- **Ограничения.**
- Действует только в рамках сообщества, для которого выдан.
- Получить ключ может только администратор сообщества.
- **Lifetime.** Не ограничен; администратор может отозвать ключ в любой момент.
- **Риски.**
- Утечка ключа с правами `manage` даёт полный контроль над сообществом.
- Требует строгого разграничения прав по ключам (принцип минимальных привилегий).
### Рекомендация для Randomayzer
| Этап | Рекомендуемый токен | Обоснование |
|---|---|---|
| MVP / текущая реализация | Сервисный ключ | Без OAuth, серверная работа, публичные посты и лайки |
| Проверка репостов в закрытых профилях | Ключ пользователя организатора | Требуется авторизация владельца стены/профиля |
| Проверка администраторов/модераторов сообщества | Ключ сообщества | `groups.getMembers filter=managers` доступно с правами администратора |
| Массовые розыгрыши 10 000+ участников | Сервисный + пакетирование (`execute`) UNVERIFIED | Высшие лимиты и отсутствие необходимости UI-авторизации |
---
## Tokens
| Тип токена | Назначение | Доступные методы (для розыгрышей) | Ограничения | Срок жизни | Риски |
|---|---|---|---|---|---|
| **Сервисный ключ** | Серверные запросы без авторизации пользователя | `wall.getById`, `likes.getList`, `wall.getComments`, `wall.getReposts`, `groups.isMember`, `groups.getMembers` | Только открытые профили/группы; репосты ограничены приватностью; `execute` — UNVERIFIED | Не ограничен | Утечка открывает публичные данные; нельзя передавать на клиент |
| **Ключ пользователя (VK ID OAuth)** | Запросы от имени пользователя | Все методы сервисного ключа + методы, требующие авторизации (`groups.get`, доступ к закрытым данным при согласии) | Требуется согласие пользователя; короткий срок жизни; refresh-логика | **1 час** | Хранение секретов/refresh; необходимость OAuth flow; отзыв прав |
| **Ключ сообщества** | Запросы от имени сообщества | Методы в рамках одного сообщества, включая `groups.getMembers filter=managers` | Только своё сообщество; нужен администратор для создания | Не ограничен | Утечка ключа с правами `manage` даёт контроль над сообществом |
### Что каждый токен может и не может делать для розыгрышей
- **Сервисный ключ**
- ✅ Получить метаданные открытого поста.
- ✅ Собрать лайки (`likes.getList`) и комментарии (`wall.getComments`).
- ✅ Проверить подписку на сообщество (`groups.isMember`).
- ❌ Получить список руководителей сообщества (`groups.getMembers filter=managers`) — UNVERIFIED, вероятно требуется ключ сообщества.
- ❌ Увидеть репосты на закрытых профилях.
- **Ключ пользователя**
- ✅ Всё, что умеет сервисный ключ (если профили/группы доступны пользователю).
- ✅ Доступ к расширенным данным при наличии соответствующих прав.
- ❌ Не даёт прав администратора чужого сообщества.
- **Ключ сообщества**
- ✅ Все операции в рамках своего сообщества.
- ✅ Проверка ролей (`groups.getMembers filter=managers`) — при наличии прав.
- ❌ Не применим к постам/группам других организаторов.
---
## Required VK methods
| Функциональность | VK Method | Токен | Права / Параметры | Ограничения |
|---|---|---|---|---|
| Загрузка метаданных поста | `wall.getById` | Сервисный или пользовательский | `posts={owner_id}_{post_id}`, `extended=1` | Возвращает массив объектов `post`; при `extended=1` дополнительно `profiles` и `groups`. Ошибка `104 Not found`, если запись недоступна. |
| Сбор лайков | `likes.getList` | Сервисный или пользовательский | `type=post`, `owner_id`, `item_id`, `filter=likes`, `extended=1`, `count` до 1000, `offset` | Максимум 1000 идентификаторов за запрос. При `filter=copies` возвращает пользователей, поделившихся записью, но только если запрос отправляет администратор группы (права редактора и выше) или владелец стены. |
| Сбор комментариев | `wall.getComments` | Сервисный или пользовательский | `owner_id`, `post_id`, `extended=1`, `count` до 100, `offset`, `fields` | Максимум 100 комментариев за запрос. При `extended=1` возвращает `profiles` и `groups`. Ошибка `212 Access to post comments denied`. |
| Проверка подписки на сообщество | `groups.isMember` | Сервисный, пользовательский или ключ сообщества | `group_id`, `user_id` или `user_ids` (до 500), `extended=1` | При пакетной проверке возвращает массив объектов `{user_id, member}`. При `extended=1` дополнительно `request`, `invitation`, `can_invite`. |
| Информация о сообществе / список участников | `groups.getMembers` | Сервисный, пользовательский или ключ сообщества | `group_id`, `count`, `offset`, `fields`, `filter` | `filter=managers` доступно при запросе от имени администратора сообщества; возвращает `role` (`advertiser`, `moderator`, `editor`, `administrator`, `creator`). Максимальное значение `count` — UNVERIFIED. |
| Сбор / проверка репостов | `wall.getReposts` | Сервисный или пользовательский | `owner_id`, `post_id`, `offset`, `count` | Возвращает `items` (записи-репосты), `profiles`, `groups`. Закрытые профили не попадают в выдачу без авторизации владельца стены. |
| Альтернативная проверка репостов | `likes.getList` с `filter=copies` | Ключ сообщества (администратор группы) или владелец стены | `type=post`, `owner_id`, `item_id`, `filter=copies`, `extended=1` | Возвращает пользователей, поделившихся записью, только при наличии соответствующих прав. |
| Проверка администраторов/модераторов | `groups.getMembers` с `filter=managers` | Ключ сообщества с правами администратора | `group_id`, `filter=managers` | Возвращает руководителей сообщества с полем `role`. Недоступно сервисному ключу — UNVERIFIED. |
### Примечания к таблице
- Типы токенов для каждого метода определены по цветовым индикаторам в официальном справочнике VK: серый — сервисный ключ, оранжевый — пользовательский, синий — ключ сообщества.
- `wall.getById`, `likes.getList`, `wall.getComments`, `wall.getReposts` имеют индикаторы сервисного и пользовательского токенов.
- `groups.isMember` и `groups.getMembers` имеют индикаторы всех трёх типов токенов.
---
## Rate Limit Strategy
> **Важно:** этот раздел описывает только архитектуру и контракты. Код не реализуется в рамках данного документа.
### Целевая архитектура
```text
VK API
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐
│ VkClient │────▶│ RateLimiter │────▶│ RetryPolicy │────▶│ VkProvider │
│ (HTTP+Auth)│ │ (token-bucket)│ │ (backoff) │ │(SocialMediaProvider)
└─────────────┘ └──────────────┘ └─────────────┘ └─────────────┘
```
`VkProvider` реализует интерфейс `SocialMediaProvider` и делегирует низкоуровневые вызовы `VkClient`. `RateLimiter` и `RetryPolicy` являются отдельными, тестируемыми абстракциями.
### Контракты / интерфейсы
```typescript
// Абстракция HTTP-клиента VK API
interface VkClient {
call<T>(method: string, params: Record<string, unknown>): Promise<VkResponse<T>>;
}
type VkResponse<T> =
| { response: T; error?: never }
| { response?: never; error: VkError };
interface VkError {
error_code: number;
error_msg: string;
}
// Ограничение скорости
interface RateLimiter {
acquire(tokenType: VkTokenType, cost?: number): Promise<void>;
updateLimits(tokenType: VkTokenType, remaining: number, resetAt: Date): void;
}
type VkTokenType = 'service' | 'user' | 'community';
// Политика повторных попыток
interface RetryPolicy {
execute<T>(task: () => Promise<T>, context: RetryContext): Promise<T>;
}
interface RetryContext {
maxAttempts: number;
isRetryable: (error: VkError) => boolean;
computeDelay: (attempt: number, error: VkError) => number;
}
```
### Exponential backoff
- Начальная задержка: 5001000 мс.
- Множитель: 2 (с jitter до 2030 %), чтобы избежать «thundering herd».
- Максимальная задержка: 3060 с.
- Общее время ожидания должно быть ограничено (например, 5 минут на одну операцию), после чего ошибка прокидывается вызывающему коду.
### Retryable vs non-retryable errors
| Код ошибки | Статус | Действие |
|---|---|---|
| `6` Too many requests per second | Retryable | Повторить после backoff, уменьшить скорость |
| `9` Flood control | Retryable | Увеличить задержку, возможно — запросить капчу UNVERIFIED |
| `10` Internal server error | Retryable | Повторить с backoff |
| `29` Rate limit reached | Retryable/Non-retryable | Дневной лимит; повторять с большим интервалом либо прекратить |
| `15` Access denied | Non-retryable | Закрытая группа/профиль; зафиксировать в аудите |
| `18` User was deleted or banned | Non-retryable | Исключить пользователя из выборки |
| `30` Private profile | Non-retryable | Исключить с причиной `PRIVATE_PROFILE_OR_NO_REPOST` |
| `104` Not found | Non-retryable | Пост не найден |
| `212` Access to post comments denied | Non-retryable | Ограничены комментарии |
| `232` Reaction can not be applied | Non-retryable | Ошибка параметров `likes.getList` |
### Rate limit handling
- Лимиты по типу токена (подтверждено документацией VK):
- Пользовательский ключ: **3 запроса/сек**.
- Ключ сообщества: **20 запросов/сек**.
- Сервисный ключ: от **5 до 60 запросов/сек** в зависимости от количества пользователей приложения.
- Рекомендуется использовать token-bucket per `VkTokenType` с консервативным начальным значением (например, 50 % от заявленного лимита) и адаптацией при получении ошибки `6`.
- Все запросы одного розыгрыша должны учитывать общий bucket, даже если они выполняются в разных корутинах/процессах.
### Pagination strategy
- `likes.getList`: `count=1000`, увеличивать `offset` на 1000 до достижения `response.count`.
- `wall.getComments`: `count=100`, увеличивать `offset` на 100.
- `groups.isMember`: батчировать `user_ids` по **500** ID.
- `groups.getMembers`: UNVERIFIED — предположительно `count` до 1000, но в документации точное максимальное значение не указано.
- При изменении данных во время pagination (например, пользователь удалил лайк) возможны дубли или пропуски. Для розыгрышей рекомендуется фиксировать `snapshotTime` и игнорировать изменения после него.
### Batching через `execute`
- Метод `execute` позволяет выполнять код на серверах VK (VKScript).
- Предполагаемые лимиты (UNVERIFIED): до 25 вызовов API внутри одного `execute`.
- Потенциальная эффективность:
- `likes.getList`: 25 × 1000 = до 25 000 лайков за 1 HTTP-запрос.
- `groups.isMember`: 25 × 500 = до 12 500 проверок подписки за 1 HTTP-запрос.
- `wall.getComments`: 25 × 100 = до 2 500 комментариев за 1 HTTP-запрос.
- **Риски:** один упавший под-вызов внутри `execute` может прервать весь батч; требуется гранулярная обработка ошибок и fallback на последовательные запросы.
### Timeout и cancellation
- Установить разумный network timeout для VK API: 3060 с.
- Поддержать `AbortSignal` / cancellation token на уровне `VkClient`, чтобы длительные операции сбора участников можно было прервать из UI.
- При отмене сохранять уже загруженные данные и статус прогресса.
---
## References
Официальная документация VK, использованная при составлении плана:
- Общий формат запросов и лимиты: https://dev.vk.ru/ru/api/api-requests
- Ключи доступа — обзор: https://dev.vk.com/api/access-token
- Сервисный ключ доступа: https://dev.vk.ru/ru/api/access-token/service-token
- Ключ доступа пользователя: https://dev.vk.ru/ru/api/access-token/user-token
- Ключ доступа сообщества: https://dev.vk.ru/ru/api/access-token/community-token
- Справочник ошибок: https://dev.vk.ru/ru/reference/errors
- Метод `wall.getById`: https://dev.vk.com/method/wall.getById
- Метод `likes.getList`: https://dev.vk.com/method/likes.getList
- Метод `wall.getComments`: https://dev.vk.com/method/wall.getComments
- Метод `groups.isMember`: https://dev.vk.com/method/groups.isMember
- Метод `groups.getMembers`: https://dev.vk.com/method/groups.getMembers
- Метод `wall.getReposts`: https://dev.vk.com/method/wall.getReposts
- Метод `execute`: https://dev.vk.com/method/execute
---
## Резюме для команды
1. **Сейчас** (`VkProvider`) используется только сервисный ключ `VK_SERVICE_TOKEN`. Это корректный MVP-подход: без OAuth, серверная работа, базовый сбор лайков/комментариев и проверка подписки.
2. **Дальнейшее развитие** требует выбора между:
- **OAuth VK ID** для получения пользовательского токена организатора (для закрытых профилей и расширенных прав);
- **ключом сообщества** (для проверки ролей админов/модераторов и работы строго в рамках одного сообщества).
3. **Перед внедрением OAuth** необходимо отдельно спроектировать поток авторизации, хранение токенов, refresh-логику и аудит действий от имени пользователя.
4. **Rate limiting** должен быть вынесен в отдельные слои `RateLimiter` и `RetryPolicy`, чтобы `VkProvider` оставался чистым адаптером `SocialMediaProvider`.
5. Все неподтверждённые официальной документацией числовые лимиты (`execute`, максимальный `count` для `groups.getMembers` и др.) помечены `UNVERIFIED` и требуют проверки в процессе разработки.

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; import { GiveawayStore } from '@/lib/giveaway-store';
import { ProviderRegistry } from '@/providers/registry'; import { ProviderRegistry } from '@/providers/registry';
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher'; import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
import { validateFilterRulesAgainstProviderCapabilities } from '@/core/filtering/rule-validation';
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
@ -19,6 +20,15 @@ export async function POST(
const rules = body.filterRules || giveaway.filterRules; const rules = body.filterRules || giveaway.filterRules;
const provider = ProviderRegistry.getProvider(giveaway.platform); const provider = ProviderRegistry.getProvider(giveaway.platform);
// Reject filter rules the selected provider cannot actually verify
const capabilityCheck = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
if (!capabilityCheck.valid) {
return NextResponse.json(
{ error: 'Unsupported filter rules', details: capabilityCheck.errors },
{ status: 400 }
);
}
// 1. Fetch raw participants // 1. Fetch raw participants
const rawParticipants = await provider.fetchParticipants({ const rawParticipants = await provider.fetchParticipants({
ownerId: giveaway.platformOwnerId, ownerId: giveaway.platformOwnerId,

View file

@ -0,0 +1,43 @@
import { FilterRules } from '../types/giveaway';
import { ProviderCapabilities } from '../../providers/types';
export interface RuleValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validates that the requested filter rules can actually be verified by the
* selected social-media provider. This prevents organizers from configuring
* giveaways with conditions (e.g. reposts) that the provider cannot check.
*/
export function validateFilterRulesAgainstProviderCapabilities(
rules: FilterRules,
capabilities: ProviderCapabilities
): RuleValidationResult {
const errors: string[] = [];
if (rules.requireRepost && !capabilities.reposts) {
errors.push(
`requireRepost is not supported by the ${capabilities.repostsNote || 'current provider'}`
);
}
if (rules.requireSubscription && !capabilities.subscriptions) {
errors.push('requireSubscription is not supported by the current provider');
}
if (rules.excludeAdmins && !capabilities.adminDetection) {
errors.push(
`excludeAdmins is not supported by the ${capabilities.adminDetectionNote || 'current provider'}`
);
}
// likes/comments are considered universally supported by providers that have
// them declared; the engine itself still needs a provider to fetch them.
return {
valid: errors.length === 0,
errors,
};
}

View file

@ -3,6 +3,77 @@ 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';
export interface MockScenario {
/** Total number of generated participants (default 35). */
participantCount: number;
/** Ratio of participants with liked=true (0..1). Default matches legacy behavior. */
likedRatio?: number;
/** Ratio of participants with commented=true (0..1). Default matches legacy behavior. */
commentedRatio?: number;
/** Number of comments per commented participant. Default matches legacy behavior. */
commentsCount?: number | ((index: number) => number);
/** Ratio of subscribed participants (0..1). When omitted, legacy "ends with 0 or 5" rule is used. */
subscribedRatio?: number;
/** IDs that should be marked as admins. */
adminIds?: string[];
/** IDs that should exist in the generated set for blacklist tests (they are not auto-excluded). */
blacklistedIds?: string[];
/** Additional raw entries injected into the returned list (useful for duplicates). */
extraParticipants?: RawParticipant[];
}
const DEFAULT_SCENARIO: MockScenario = {
participantCount: 35,
};
const MOCK_NAMES = [
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' },
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' },
{ first: 'Дмитрий', last: 'Кузнецов', user: 'kuznetsov_d' },
{ first: 'Анна', last: 'Попова', user: 'anna_popova' },
{ first: 'Михаил', last: 'Соколов', user: 'misha_sokol' },
{ first: 'Елена', last: 'Лебедева', user: 'elena_leb' },
{ first: 'Сергей', last: 'Козлов', user: 'sergey_kozlov' },
{ first: 'Ольга', last: 'Новикова', user: 'olga_nov' },
{ first: 'Иван', last: 'Морозов', user: 'ivan_moroz' },
{ first: 'Татьяна', last: 'Петрова', user: 'tatyana_p' },
{ first: 'Артем', last: 'Волков', user: 'artem_volkov' },
{ first: 'Мария', last: 'Соловьева', user: 'maria_sol' },
{ first: 'Максим', last: 'Васильев', user: 'max_vas' },
{ first: 'Виктория', last: 'Зайцева', user: 'vika_zaytseva' },
{ first: 'Павел', last: 'Павлов', user: 'pavel_p' },
{ first: 'Ксения', last: 'Семенова', user: 'ksenia_sem' },
{ first: 'Роман', last: 'Голубев', user: 'roman_g' },
{ first: 'Алина', last: 'Виноградова', user: 'alina_vin' },
{ first: 'Денис', last: 'Богданов', user: 'denis_bogdan' },
{ first: 'Анастасия', last: 'Воробьева', user: 'nastya_vorob' },
{ first: 'Илья', last: 'Федоров', user: 'ilya_fed' },
{ first: 'Полина', last: 'Михайлова', user: 'polina_m' },
{ first: 'Владимир', last: 'Беляев', user: 'vlad_bel' },
{ first: 'Дарья', last: 'Тарасова', user: 'daria_t' },
{ first: 'Никита', last: 'Белов', user: 'nikita_bel' },
];
/**
* Deterministic pseudo-random value in [0, 1) based on an integer seed.
* Uses a simple LCG so mock scenarios are reproducible across test runs.
*/
function deterministic01(seed: number): number {
return ((seed * 9301 + 49297) % 233280) / 233280;
}
function legacyLiked(i: number): boolean {
return i !== 7 && i !== 19;
}
function legacyCommented(i: number): boolean {
return i % 2 === 0 || i % 3 === 0;
}
function legacyCommentsCount(i: number): number {
return legacyCommented(i) ? (i % 5 === 0 ? 3 : 1) : 0;
}
export class VkMockProvider implements SocialMediaProvider { export class VkMockProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK'; readonly platform: PlatformType = 'VK';
readonly capabilities: ProviderCapabilities = { readonly capabilities: ProviderCapabilities = {
@ -15,10 +86,30 @@ export class VkMockProvider implements SocialMediaProvider {
adminDetectionNote: 'Требует расширенных прав администратора сообщества', adminDetectionNote: 'Требует расширенных прав администратора сообщества',
}; };
private scenario: MockScenario = { ...DEFAULT_SCENARIO };
parsePostUrl(url: string): { ownerId: string; postId: string } | null { parsePostUrl(url: string): { ownerId: string; postId: string } | null {
return parseVkPostUrl(url); return parseVkPostUrl(url);
} }
/**
* Configure the mock scenario. Does not change the public SocialMediaProvider contract.
*/
setScenario(scenario: Partial<MockScenario>): void {
this.scenario = { ...DEFAULT_SCENARIO, ...scenario };
}
/**
* Reset to the original 35-participant default scenario.
*/
resetScenario(): void {
this.scenario = { ...DEFAULT_SCENARIO };
}
getScenario(): MockScenario {
return { ...this.scenario };
}
async fetchPost(url: string): Promise<PostMetadata> { async fetchPost(url: string): Promise<PostMetadata> {
const parsed = this.parsePostUrl(url); const parsed = this.parsePostUrl(url);
const ownerId = parsed ? parsed.ownerId : '-22446688'; const ownerId = parsed ? parsed.ownerId : '-22446688';
@ -43,51 +134,45 @@ export class VkMockProvider implements SocialMediaProvider {
}; };
} }
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> { async fetchParticipants(_params: FetchParticipantsParams): Promise<RawParticipant[]> {
const mockNames = [ const {
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' }, participantCount,
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' }, likedRatio,
{ first: 'Дмитрий', last: 'Кузнецов', user: 'kuznetsov_d' }, commentedRatio,
{ first: 'Анна', last: 'Попова', user: 'anna_popova' }, commentsCount: commentsCountOverride,
{ first: 'Михаил', last: 'Соколов', user: 'misha_sokol' }, adminIds = [],
{ first: 'Елена', last: 'Лебедева', user: 'elena_leb' }, extraParticipants = [],
{ first: 'Сергей', last: 'Козлов', user: 'sergey_kozlov' }, } = this.scenario;
{ first: 'Ольга', last: 'Новикова', user: 'olga_nov' },
{ first: 'Иван', last: 'Морозов', user: 'ivan_moroz' }, const adminSet = new Set(adminIds.map(id => id.trim()));
{ first: 'Татьяна', last: 'Петрова', user: 'tatyana_p' },
{ first: 'Артем', last: 'Волков', user: 'artem_volkov' },
{ first: 'Мария', last: 'Соловьева', user: 'maria_sol' },
{ first: 'Максим', last: 'Васильев', user: 'max_vas' },
{ first: 'Виктория', last: 'Зайцева', user: 'vika_zaytseva' },
{ first: 'Павел', last: 'Павлов', user: 'pavel_p' },
{ first: 'Ксения', last: 'Семенова', user: 'ksenia_sem' },
{ first: 'Роман', last: 'Голубев', user: 'roman_g' },
{ first: 'Алина', last: 'Виноградова', user: 'alina_vin' },
{ first: 'Денис', last: 'Богданов', user: 'denis_bogdan' },
{ first: 'Анастасия', last: 'Воробьева', user: 'nastya_vorob' },
{ first: 'Илья', last: 'Федоров', user: 'ilya_fed' },
{ first: 'Полина', last: 'Михайлова', user: 'polina_m' },
{ first: 'Владимир', last: 'Беляев', user: 'vlad_bel' },
{ first: 'Дарья', last: 'Тарасова', user: 'daria_t' },
{ first: 'Никита', last: 'Белов', user: 'nikita_bel' },
];
const participants: RawParticipant[] = []; const participants: RawParticipant[] = [];
for (let i = 1; i <= 35; i++) { for (let i = 1; i <= participantCount; i++) {
const nameObj = mockNames[(i - 1) % mockNames.length]; const nameObj = MOCK_NAMES[(i - 1) % MOCK_NAMES.length];
const userId = `${1000000 + i * 137}`; const userId = `${1000000 + i * 137}`;
const liked = i !== 7 && i !== 19; const liked = likedRatio !== undefined
const commented = i % 2 === 0 || i % 3 === 0; ? deterministic01(i * 7 + 1) < likedRatio
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0; : legacyLiked(i);
const commented = commentedRatio !== undefined
? deterministic01(i * 13 + 3) < commentedRatio
: legacyCommented(i);
const commentsCount = typeof commentsCountOverride === 'function'
? commentsCountOverride(i)
: commentsCountOverride !== undefined
? (commented ? commentsCountOverride : 0)
: legacyCommentsCount(i);
const reposted = false; // explicitly false as per capabilities const reposted = false; // explicitly false as per capabilities
const subscribed = false; // will be resolved via checkSubscription const subscribed = false; // resolved via checkSubscription
const isAdmin = false; const isAdmin = adminSet.has(userId);
participants.push({ participants.push({
platformUserId: userId, platformUserId: userId,
firstName: nameObj.first + (i > mockNames.length ? ` ${Math.floor(i / mockNames.length) + 1}` : ''), firstName: nameObj.first + (i > MOCK_NAMES.length ? ` ${Math.floor(i / MOCK_NAMES.length) + 1}` : ''),
lastName: nameObj.last, lastName: nameObj.last,
username: `${nameObj.user}_${i}`, username: `${nameObj.user}_${i}`,
avatarUrl: `https://images.unsplash.com/photo-${1534528741775 + (i * 1000)}?w=100&auto=format&fit=crop&q=80`, avatarUrl: `https://images.unsplash.com/photo-${1534528741775 + (i * 1000)}?w=100&auto=format&fit=crop&q=80`,
@ -101,16 +186,28 @@ export class VkMockProvider implements SocialMediaProvider {
}); });
} }
if (extraParticipants.length > 0) {
participants.push(...extraParticipants);
}
return participants; return participants;
} }
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> { async checkSubscription(userIds: string[], _groupId: string): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>(); const result = new Map<string, boolean>();
const { subscribedRatio } = this.scenario;
for (const id of userIds) { for (const id of userIds) {
// Mock: users ending in 0 or 5 are not subscribed, others are subscribed
const num = parseInt(id, 10); const num = parseInt(id, 10);
result.set(id, num % 5 !== 0);
if (subscribedRatio !== undefined) {
result.set(id, deterministic01(num * 17 + 11) < subscribedRatio);
} else {
// Legacy behavior: users ending in 0 or 5 are not subscribed
result.set(id, num % 5 !== 0);
}
} }
return result; return result;
} }
} }

View file

@ -0,0 +1,303 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GiveawayStore } from '../src/lib/giveaway-store';
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
import { ProviderRegistry } from '../src/providers/registry';
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
import { POST as previewPost } from '../src/app/api/posts/preview/route';
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
import { GET as giveawayGet } from '../src/app/api/giveaways/[id]/route';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
async function createGiveaway(overrides: Partial<{ filterRules: typeof DEFAULT_FILTER_RULES; winnersCount: number; reserveWinnersCount: number; seed: string }> = {}) {
return GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-100_1',
post: {
platform: 'VK',
ownerId: '-100',
postId: '1',
sourceUrl: 'https://vk.com/wall-100_1',
title: 'Test',
text: 'Test',
likesCount: 10,
commentsCount: 5,
repostsCount: 2,
},
filterRules: overrides.filterRules || DEFAULT_FILTER_RULES,
winnersCount: overrides.winnersCount ?? 1,
reserveWinnersCount: overrides.reserveWinnersCount ?? 0,
seed: overrides.seed,
});
}
const sampleParticipants: FilteredParticipant[] = Array.from({ length: 5 }, (_, i) => ({
platformUserId: `${1000 + i}`,
firstName: 'User',
lastName: `${i}`,
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
}));
describe('API input validation', () => {
beforeEach(() => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
});
describe('POST /api/giveaways', () => {
it('returns 400 when sourceUrl is missing', async () => {
const req = new NextRequest('http://localhost/api/giveaways', {
method: 'POST',
body: JSON.stringify({ post: {} }),
});
const res = await giveawaysPost(req);
expect(res.status).toBe(400);
});
it('returns 400 when post is missing', async () => {
const req = new NextRequest('http://localhost/api/giveaways', {
method: 'POST',
body: JSON.stringify({ sourceUrl: 'https://vk.com/wall-1_1' }),
});
const res = await giveawaysPost(req);
expect(res.status).toBe(400);
});
it('returns 500 for malformed JSON body', async () => {
const req = new NextRequest('http://localhost/api/giveaways', {
method: 'POST',
body: 'not-json',
});
const res = await giveawaysPost(req);
expect(res.status).toBe(500);
});
});
describe('POST /api/posts/preview', () => {
it('returns 400 for invalid VK URL', async () => {
const req = new NextRequest('http://localhost/api/posts/preview', {
method: 'POST',
body: JSON.stringify({ url: 'https://google.com' }),
});
const res = await previewPost(req);
expect(res.status).toBe(400);
});
it('returns 400 when URL is missing', async () => {
const req = new NextRequest('http://localhost/api/posts/preview', {
method: 'POST',
body: JSON.stringify({}),
});
const res = await previewPost(req);
expect(res.status).toBe(400);
});
it('returns 500 for malformed JSON', async () => {
const req = new NextRequest('http://localhost/api/posts/preview', {
method: 'POST',
body: '{ broken',
});
const res = await previewPost(req);
expect(res.status).toBe(500);
});
});
describe('POST /api/giveaways/:id/draw', () => {
it('returns 404 for non-existent giveaway', async () => {
const req = new NextRequest('http://localhost/api/giveaways/does-not-exist/draw', {
method: 'POST',
body: JSON.stringify({}),
});
const res = await drawPost(req, { params: { id: 'does-not-exist' } });
expect(res.status).toBe(404);
});
it('returns 400 when drawing a giveaway that is already DRAWN', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, sampleParticipants, DEFAULT_FILTER_RULES);
// First draw
const firstReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({}),
});
const firstRes = await drawPost(firstReq, { params: { id: gw.id } });
expect(firstRes.status).toBe(200);
// Second draw attempt
const secondReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({}),
});
const secondRes = await drawPost(secondReq, { params: { id: gw.id } });
expect(secondRes.status).toBe(400);
const data = await secondRes.json();
expect(data.error).toMatch(/уже проведен|already drawn/i);
});
it('returns 400 when there are 0 eligible participants', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({}),
});
const res = await drawPost(req, { params: { id: gw.id } });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/Нет допущенных|0 eligible/i);
});
it('currently accepts winnersCount = -1 (documented validation gap)', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ winnersCount: -1 }),
});
const res = await drawPost(req, { params: { id: gw.id } });
// Current behavior: does not reject negative winnersCount; it caps to pool size.
expect(res.status).toBe(200);
const data = await res.json();
expect(data.drawResult.winners).toHaveLength(0);
});
it('currently treats winnersCount = 0 as the giveaway default (documented validation gap)', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ winnersCount: 0 }),
});
const res = await drawPost(req, { params: { id: gw.id } });
// The route uses `body.winnersCount || giveaway.winnersCount || 1`, so 0 is ignored.
expect(res.status).toBe(200);
const data = await res.json();
expect(data.drawResult.winners).toHaveLength(1);
});
it('caps winnersCount = 999999999 to pool size', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ winnersCount: 999999999 }),
});
const res = await drawPost(req, { params: { id: gw.id } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.drawResult.winners).toHaveLength(5);
});
it('currently accepts reserveWinnersCount < 0 (documented validation gap)', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ reserveWinnersCount: -5 }),
});
const res = await drawPost(req, { params: { id: gw.id } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.drawResult.reserveWinners).toHaveLength(0);
});
it('uses generated seed when empty seed is provided', async () => {
const gw = await createGiveaway({ seed: undefined });
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: ' ' }),
});
const res = await drawPost(req, { params: { id: gw.id } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.drawResult.seedUsed).toBeTruthy();
expect(data.drawResult.seedUsed.trim().length).toBeGreaterThan(0);
});
it('currently accepts huge seed strings (documented validation gap)', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const hugeSeed = 'a'.repeat(100_000);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: hugeSeed }),
});
const res = await drawPost(req, { params: { id: gw.id } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.drawResult.seedUsed).toBe(hugeSeed);
});
it('currently swallows malformed JSON body and proceeds (documented validation gap)', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: 'not-json',
});
const res = await drawPost(req, { params: { id: gw.id } });
// `.catch(() => ({}))` silently turns malformed JSON into an empty body.
expect(res.status).toBe(200);
});
});
describe('POST /api/giveaways/:id/participants', () => {
it('currently accepts unknown filter rules in body (documented validation gap)', async () => {
const gw = await createGiveaway();
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
method: 'POST',
body: JSON.stringify({
filterRules: {
...DEFAULT_FILTER_RULES,
unknownRule: true,
anotherBadField: 'x',
},
}),
});
const res = await participantsPost(req, { params: { id: gw.id } });
expect(res.status).toBe(200);
});
it('returns 404 for non-existent giveaway', async () => {
const req = new NextRequest('http://localhost/api/giveaways/missing/participants', {
method: 'POST',
body: JSON.stringify({}),
});
const res = await participantsPost(req, { params: { id: 'missing' } });
expect(res.status).toBe(404);
});
});
describe('POST /api/giveaways/:id/snapshot', () => {
it('returns 400 when there are 0 eligible participants', async () => {
const gw = await createGiveaway();
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
method: 'POST',
body: JSON.stringify({}),
});
const res = await snapshotPost(req, { params: { id: gw.id } });
expect(res.status).toBe(400);
});
});
describe('GET /api/giveaways/:id', () => {
it('returns 404 for non-existent giveaway', async () => {
const req = new NextRequest('http://localhost/api/giveaways/missing');
const res = await giveawayGet(req, { params: { id: 'missing' } });
expect(res.status).toBe(404);
});
});
});

139
tests/concurrency.test.ts Normal file
View file

@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GiveawayStore } from '../src/lib/giveaway-store';
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
import { ProviderRegistry } from '../src/providers/registry';
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
async function createReadyGiveaway() {
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-100_1',
post: {
platform: 'VK',
ownerId: '-100',
postId: '1',
sourceUrl: 'https://vk.com/wall-100_1',
title: 'Test',
text: 'Test',
likesCount: 10,
commentsCount: 5,
repostsCount: 2,
},
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 1,
reserveWinnersCount: 0,
});
const participants: FilteredParticipant[] = Array.from({ length: 10 }, (_, i) => ({
platformUserId: `${1000 + i}`,
firstName: 'User',
lastName: `${i}`,
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
}));
await GiveawayStore.updateParticipants(gw.id, participants);
return gw;
}
describe('Concurrency analysis', () => {
beforeEach(() => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
});
it('documents current double-draw race behavior (both requests may succeed)', async () => {
const gw = await createReadyGiveaway();
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: 'race-seed-1' }),
});
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: 'race-seed-2' }),
});
const [res1, res2] = await Promise.all([
drawPost(req1, { params: { id: gw.id } }),
drawPost(req2, { params: { id: gw.id } }),
]);
// This assertion captures the CURRENT behavior so the test passes today.
// If the race is fixed, the previous `it.failing` will start passing and
// this test should be updated to assert one failure.
expect([res1.status, res2.status]).toContain(200);
});
it('should not corrupt giveaway state when snapshot and draw race', async () => {
const gw = await createReadyGiveaway();
const snapshotReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
method: 'POST',
body: JSON.stringify({}),
});
const drawReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
method: 'POST',
body: JSON.stringify({ seed: 'race-seed' }),
});
const [snapRes, drawRes] = await Promise.all([
snapshotPost(snapshotReq, { params: { id: gw.id } }),
drawPost(drawReq, { params: { id: gw.id } }),
]);
// At least one operation must succeed; both should not silently corrupt.
expect([snapRes.status, drawRes.status]).toContain(200);
const final = await GiveawayStore.getById(gw.id);
expect(final).not.toBeNull();
// After any successful draw the status must be DRAWN.
if (drawRes.status === 200) {
expect(final?.status).toBe('DRAWN');
}
});
it('should not allow participant import to overwrite a DRAWN giveaway', async () => {
const gw = await createReadyGiveaway();
const refreshed = await GiveawayStore.getById(gw.id);
const eligible = refreshed!.participants.filter(p => p.eligible);
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, eligible, DEFAULT_FILTER_RULES);
await GiveawayStore.saveDrawResult(gw.id, snapshot.id, {
drawId: 'draw-test',
giveawayId: gw.id,
snapshotId: snapshot.id,
winners: [],
reserveWinners: [],
winnerIds: [],
reserveWinnerIds: [],
totalEligibleCount: snapshot.participantCount,
totalLoadedCount: gw.participants.length,
seedUsed: 'seed',
participantsSnapshotHash: snapshot.participantsSnapshotHash,
conditionsHash: snapshot.conditionsHash,
algorithmVersion: 'HMAC_SHA256_FY_V1',
drawnAt: new Date().toISOString(),
auditHash: 'audit',
});
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
method: 'POST',
body: JSON.stringify({}),
});
const res = await participantsPost(req, { params: { id: gw.id } });
// The route catches the FSM error and returns 500; the important thing is
// that the DRAWN giveaway is not silently overwritten.
expect(res.status).toBeGreaterThanOrEqual(400);
});
});

View file

@ -194,4 +194,251 @@ describe('Filter Engine', () => {
expect(result.stats.eligibleCount).toBe(1); expect(result.stats.eligibleCount).toBe(1);
expect(result.allParticipants[0].commentsCount).toBe(3); expect(result.allParticipants[0].commentsCount).toBe(3);
}); });
describe('rule combination matrix', () => {
function makeParticipant(
id: string,
overrides: Partial<RawParticipant> = {}
): RawParticipant {
return {
platformUserId: id,
firstName: 'User',
lastName: id,
source: 'LIKES',
liked: false,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false,
isAdmin: false,
...overrides,
};
}
it('should require like only', () => {
const participants = [
makeParticipant('1', { liked: true }),
makeParticipant('2', { liked: false }),
];
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['1']);
expect(result.excludedParticipants[0].exclusionReason).toBe('MISSING_LIKE');
});
it('should require comment only', () => {
const participants = [
makeParticipant('1', { commented: true, commentsCount: 1 }),
makeParticipant('2', { commented: false, commentsCount: 0 }),
];
const rules: FilterRules = {
requireLike: false,
requireComment: true,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['1']);
expect(result.excludedParticipants[0].exclusionReason).toBe('MISSING_COMMENT');
});
it('should require subscription only', () => {
const participants = [
makeParticipant('1', { subscribed: true }),
makeParticipant('2', { subscribed: false }),
];
const rules: FilterRules = {
requireLike: false,
requireComment: false,
requireRepost: false,
requireSubscription: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['1']);
expect(result.excludedParticipants[0].exclusionReason).toBe('NOT_SUBSCRIBED');
});
it('should require like + comment together', () => {
const participants = [
makeParticipant('1', { liked: true, commented: true, commentsCount: 1 }),
makeParticipant('2', { liked: true, commented: false, commentsCount: 0 }),
makeParticipant('3', { liked: false, commented: true, commentsCount: 1 }),
];
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['1']);
expect(result.excludedParticipants.map(p => p.exclusionReason)).toContain('MISSING_COMMENT');
expect(result.excludedParticipants.map(p => p.exclusionReason)).toContain('MISSING_LIKE');
});
it('should combine admin exclusion and subscription requirement', () => {
const participants = [
makeParticipant('1', { liked: true, subscribed: true, isAdmin: false }),
makeParticipant('2', { liked: true, subscribed: true, isAdmin: true }),
makeParticipant('3', { liked: true, subscribed: false, isAdmin: false }),
];
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: true,
excludeAdmins: true,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['1']);
expect(result.excludedParticipants.map(p => p.platformUserId).sort()).toEqual(['2', '3']);
expect(result.excludedParticipants.find(p => p.platformUserId === '2')?.exclusionReason).toContain('IS_ADMIN');
expect(result.excludedParticipants.find(p => p.platformUserId === '3')?.exclusionReason).toContain('NOT_SUBSCRIBED');
});
it('should combine blacklist with action requirements', () => {
const participants = [
makeParticipant('1', { liked: true }),
makeParticipant('2', { liked: true }),
makeParticipant('3', { liked: false }),
];
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: ['2'],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['1']);
const excluded2 = result.excludedParticipants.find(p => p.platformUserId === '2');
expect(excluded2?.exclusionReason).toContain('BLACKLISTED');
const excluded3 = result.excludedParticipants.find(p => p.platformUserId === '3');
expect(excluded3?.exclusionReason).toContain('MISSING_LIKE');
});
it('should aggregate duplicate users with both LIKE and COMMENT into a single eligible participant', () => {
const participants: RawParticipant[] = [
{
platformUserId: '10',
firstName: 'Like',
lastName: 'Only',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
},
{
platformUserId: '10',
firstName: 'Comment',
lastName: 'Only',
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
},
];
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.stats.total).toBe(1);
expect(result.stats.eligibleCount).toBe(1);
const merged = result.eligibleParticipants[0];
expect(merged.liked).toBe(true);
expect(merged.commented).toBe(true);
expect(merged.commentsCount).toBe(1);
});
it('should exclude a user who satisfies only some of several mandatory conditions', () => {
const participants = [
makeParticipant('all', { liked: true, commented: true, commentsCount: 1, reposted: true, subscribed: true }),
makeParticipant('missing-repost', { liked: true, commented: true, commentsCount: 1, reposted: false, subscribed: true }),
makeParticipant('missing-sub', { liked: true, commented: true, commentsCount: 1, reposted: true, subscribed: false }),
];
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: true,
requireSubscription: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['all']);
expect(result.excludedParticipants).toHaveLength(2);
expect(result.excludedParticipants.map(p => p.exclusionReason)).toContain('MISSING_REPOST');
expect(result.excludedParticipants.map(p => p.exclusionReason)).toContain('NOT_SUBSCRIBED');
});
it('should report all reasons for exclusion when multiple conditions fail', () => {
const participant = makeParticipant('bad', { liked: false, commented: false, subscribed: false });
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules([participant], rules);
expect(result.excludedParticipants[0].exclusionReason?.split(', ').sort()).toEqual([
'MISSING_COMMENT',
'MISSING_LIKE',
'NOT_SUBSCRIBED',
]);
});
it('should handle username-based blacklist matching', () => {
const participants = [
makeParticipant('1', { username: 'spammer' }),
makeParticipant('2', { username: 'gooduser' }),
];
const rules: FilterRules = {
requireLike: false,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: ['@Spammer'],
excludeDuplicateComments: true,
};
const result = applyFilterRules(participants, rules);
expect(result.eligibleParticipants.map(p => p.platformUserId)).toEqual(['2']);
expect(result.excludedParticipants[0].platformUserId).toBe('1');
});
});
}); });

View file

@ -0,0 +1,266 @@
import { describe, it, expect, vi } from 'vitest';
import { executeParticipantPipeline } from '../src/core/pipeline/participant-enricher';
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
import { FilterRules } from '../src/core/types/giveaway';
import { SocialMediaProvider } from '../src/providers/types';
import { RawParticipant } from '../src/core/types/participant';
describe('Participant Pipeline', () => {
function buildRules(overrides: Partial<FilterRules> = {}): FilterRules {
return {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
...overrides,
};
}
it('full pipeline: fetch -> enrich -> filter -> eligible', async () => {
const provider = new VkMockProvider();
provider.setScenario({ participantCount: 20, likedRatio: 1, subscribedRatio: 1 });
const rawParticipants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const rules = buildRules({ requireSubscription: true, targetGroupId: '-100' });
const result = await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(result.stats.total).toBe(20);
expect(result.eligibleParticipants.length).toBe(20);
expect(result.eligibleParticipants.every(p => p.subscribed)).toBe(true);
});
it('calls provider.checkSubscription when requireSubscription is true', async () => {
const provider = new VkMockProvider();
provider.setScenario({ participantCount: 5, subscribedRatio: 0.5 });
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
const rawParticipants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const rules = buildRules({ requireSubscription: true, targetGroupId: '-100' });
await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(checkSubscriptionSpy).toHaveBeenCalledTimes(1);
expect(checkSubscriptionSpy).toHaveBeenCalledWith(
expect.arrayContaining(rawParticipants.map(p => p.platformUserId)),
'-100'
);
});
it('does NOT call provider.checkSubscription when requireSubscription is false', async () => {
const provider = new VkMockProvider();
provider.setScenario({ participantCount: 5 });
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
const rawParticipants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const rules = buildRules({ requireSubscription: false });
await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(checkSubscriptionSpy).not.toHaveBeenCalled();
});
it('subscription result affects eligibility', async () => {
const provider = new VkMockProvider();
provider.setScenario({ participantCount: 10, likedRatio: 1, subscribedRatio: 0 });
const rawParticipants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const rules = buildRules({ requireSubscription: true, targetGroupId: '-100' });
const result = await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(result.eligibleParticipants).toHaveLength(0);
expect(result.excludedParticipants).toHaveLength(10);
expect(result.excludedParticipants[0].exclusionReason).toContain('NOT_SUBSCRIBED');
});
it('uses ownerId as targetGroupId when targetGroupId is not provided for group owner', async () => {
const provider = new VkMockProvider();
provider.setScenario({ participantCount: 3, subscribedRatio: 1 });
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
const rawParticipants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const rules = buildRules({ requireSubscription: true }); // no targetGroupId
await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(checkSubscriptionSpy).toHaveBeenCalledWith(
expect.arrayContaining(rawParticipants.map(p => p.platformUserId)),
'-100'
);
});
it('skips subscription check for personal walls without targetGroupId', async () => {
const provider = new VkMockProvider();
provider.setScenario({ participantCount: 3 });
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
const rawParticipants = await provider.fetchParticipants({
ownerId: '100',
postId: '1',
});
const rules = buildRules({ requireSubscription: true }); // no targetGroupId, owner is user
const result = await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '100',
});
expect(checkSubscriptionSpy).not.toHaveBeenCalled();
// Participants remain subscribed=false, so they are excluded
expect(result.eligibleParticipants).toHaveLength(0);
});
it('pipeline filters by combined conditions after enrichment', async () => {
const provider = new VkMockProvider();
provider.setScenario({
participantCount: 50,
likedRatio: 0.8,
commentedRatio: 0.6,
subscribedRatio: 0.7,
});
const rawParticipants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const rules = buildRules({
requireLike: true,
requireComment: true,
requireSubscription: true,
targetGroupId: '-100',
});
const result = await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(result.stats.total).toBe(50);
expect(result.eligibleParticipants.length).toBeGreaterThan(0);
expect(result.eligibleParticipants.length).toBeLessThan(50);
expect(result.eligibleParticipants.every(p => p.liked && p.commented && p.subscribed)).toBe(true);
});
it('pipeline handles duplicate users across sources correctly', async () => {
const provider: SocialMediaProvider = {
platform: 'VK',
capabilities: {
likes: true,
comments: true,
reposts: false,
subscriptions: true,
adminDetection: false,
},
parsePostUrl: () => ({ ownerId: '-100', postId: '1' }),
fetchPost: async () => ({} as any),
fetchParticipants: async () => [],
checkSubscription: async (userIds: string[]) => {
const map = new Map<string, boolean>();
userIds.forEach(id => map.set(id, true));
return map;
},
};
const rawParticipants: RawParticipant[] = [
{
platformUserId: '42',
firstName: 'A',
lastName: 'B',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false,
},
{
platformUserId: '42',
firstName: 'A',
lastName: 'B',
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 2,
reposted: false,
subscribed: false,
},
];
const rules = buildRules({
requireLike: true,
requireComment: true,
requireSubscription: true,
targetGroupId: '-100',
});
const result = await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: '-100',
});
expect(result.stats.total).toBe(1);
expect(result.eligibleParticipants).toHaveLength(1);
const merged = result.eligibleParticipants[0];
expect(merged.liked).toBe(true);
expect(merged.commented).toBe(true);
expect(merged.commentsCount).toBe(2);
expect(merged.subscribed).toBe(true);
});
});

View file

@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { validateFilterRulesAgainstProviderCapabilities } from '../src/core/filtering/rule-validation';
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
import { VkProvider } from '../src/providers/vk/vk-provider';
import { ProviderRegistry } from '../src/providers/registry';
import { FilterRules } from '../src/core/types/giveaway';
import { NextRequest } from 'next/server';
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
import { GiveawayStore } from '../src/lib/giveaway-store';
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
async function createGiveaway(store: typeof GiveawayStore) {
return store.create({
sourceUrl: 'https://vk.com/wall-100_1',
post: {
platform: 'VK',
ownerId: '-100',
postId: '1',
sourceUrl: 'https://vk.com/wall-100_1',
title: 'Test',
text: 'Test',
likesCount: 0,
commentsCount: 0,
repostsCount: 0,
},
filterRules: {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
},
winnersCount: 1,
reserveWinnersCount: 0,
});
}
function buildReq(id: string, body: object): NextRequest {
return new NextRequest(`http://localhost/api/giveaways/${id}/participants`, {
method: 'POST',
body: JSON.stringify(body),
});
}
describe('Provider capabilities', () => {
beforeEach(() => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
});
it('VK mock provider declares reposts=false and adminDetection=false', () => {
const provider = new VkMockProvider();
expect(provider.capabilities.reposts).toBe(false);
expect(provider.capabilities.adminDetection).toBe(false);
expect(provider.capabilities.subscriptions).toBe(true);
});
it('VK real provider declares reposts=false and adminDetection=false', () => {
const provider = new VkProvider('dummy-token');
expect(provider.capabilities.reposts).toBe(false);
expect(provider.capabilities.adminDetection).toBe(false);
expect(provider.capabilities.subscriptions).toBe(true);
});
it('validation rejects requireRepost when provider cannot verify reposts', () => {
const rules: FilterRules = {
requireLike: false,
requireComment: false,
requireRepost: true,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const provider = new VkMockProvider();
const result = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('requireRepost'))).toBe(true);
});
it('validation rejects excludeAdmins when provider cannot detect admins', () => {
const rules: FilterRules = {
requireLike: false,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: true,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const provider = new VkMockProvider();
const result = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('excludeAdmins'))).toBe(true);
});
it('validation accepts supported combinations', () => {
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: true,
targetGroupId: '-100',
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const provider = new VkMockProvider();
const result = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('participants route returns 400 when requireRepost is requested for VK', async () => {
const gw = await createGiveaway(GiveawayStore);
const req = buildReq(gw.id, {
filterRules: {
requireLike: true,
requireComment: false,
requireRepost: true,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
},
});
const res = await participantsPost(req, { params: { id: gw.id } });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/Unsupported filter rules/i);
expect(data.details.some((d: string) => d.includes('requireRepost'))).toBe(true);
});
it('participants route returns 400 when excludeAdmins is requested for VK', async () => {
const gw = await createGiveaway(GiveawayStore);
const req = buildReq(gw.id, {
filterRules: {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: true,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
},
});
const res = await participantsPost(req, { params: { id: gw.id } });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.details.some((d: string) => d.includes('excludeAdmins'))).toBe(true);
});
it('participants route succeeds for supported VK rules', async () => {
const gw = await createGiveaway(GiveawayStore);
const req = buildReq(gw.id, {
filterRules: {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: true,
targetGroupId: '-100',
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
},
});
const res = await participantsPost(req, { params: { id: gw.id } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.eligibleCount + data.excludedCount).toBeGreaterThan(0);
});
});

135
tests/security.test.ts Normal file
View file

@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProviderRegistry } from '../src/providers/registry';
import { VkProvider } from '../src/providers/vk/vk-provider';
import { GiveawayStore } from '../src/lib/giveaway-store';
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
import { NextRequest } from 'next/server';
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
import { POST as previewPost } from '../src/app/api/posts/preview/route';
import { readFileSync } from 'fs';
import { resolve } from 'path';
describe('Security: VK_SERVICE_TOKEN handling', () => {
const secretToken = 'vk1.a.super-secret-service-token-xyz';
beforeEach(() => {
GiveawayStore.setRepository(new MemoryGiveawayRepository());
ProviderRegistry.useMockVk();
});
it('ProviderRegistry does not expose VK_SERVICE_TOKEN in public API', () => {
process.env.VK_SERVICE_TOKEN = secretToken;
// Re-initialize registry (static block already ran, but we can inspect provider)
const provider = ProviderRegistry.getProvider('VK');
expect(provider.platform).toBe('VK');
expect(provider).not.toHaveProperty('serviceToken');
delete process.env.VK_SERVICE_TOKEN;
});
it('Giveaway API response does not contain VK_SERVICE_TOKEN', async () => {
process.env.VK_SERVICE_TOKEN = secretToken;
const req = new NextRequest('http://localhost/api/giveaways', {
method: 'POST',
body: JSON.stringify({
sourceUrl: 'https://vk.com/wall-1_1',
post: {
platform: 'VK',
ownerId: '-1',
postId: '1',
sourceUrl: 'https://vk.com/wall-1_1',
title: 'Test',
text: 'Test',
likesCount: 0,
commentsCount: 0,
repostsCount: 0,
},
filterRules: {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
},
}),
});
const res = await giveawaysPost(req);
const text = await res.text();
expect(text).not.toContain(secretToken);
delete process.env.VK_SERVICE_TOKEN;
});
it('Post preview response does not contain VK_SERVICE_TOKEN', async () => {
process.env.VK_SERVICE_TOKEN = secretToken;
ProviderRegistry.useMockVk(); // mock so no real API call
const req = new NextRequest('http://localhost/api/posts/preview', {
method: 'POST',
body: JSON.stringify({ url: 'https://vk.com/wall-1_1' }),
});
const res = await previewPost(req);
const text = await res.text();
expect(text).not.toContain(secretToken);
delete process.env.VK_SERVICE_TOKEN;
});
it('Created giveaway does not persist VK_SERVICE_TOKEN', async () => {
process.env.VK_SERVICE_TOKEN = secretToken;
const repo = new MemoryGiveawayRepository();
GiveawayStore.setRepository(repo);
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-1_1',
post: {
platform: 'VK',
ownerId: '-1',
postId: '1',
sourceUrl: 'https://vk.com/wall-1_1',
title: 'Test',
text: 'Test',
likesCount: 0,
commentsCount: 0,
repostsCount: 0,
},
filterRules: {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
},
});
const json = JSON.stringify(gw);
expect(json).not.toContain(secretToken);
delete process.env.VK_SERVICE_TOKEN;
});
it('VkProvider error messages do not contain the token', async () => {
const provider = new VkProvider(secretToken);
let thrownMessage = '';
try {
// Force error by calling with no token? Provider has token, but callApi will fail network.
await provider.fetchPost('not-a-valid-url');
} catch (err: any) {
thrownMessage = err.message || '';
}
expect(thrownMessage).not.toContain(secretToken);
});
it('.gitignore excludes local environment files', () => {
const gitignore = readFileSync(resolve(__dirname, '../.gitignore'), 'utf-8');
expect(gitignore).toContain('.env');
expect(gitignore).toContain('.env*.local');
});
it('.env.example does not contain real secrets', () => {
const envExample = readFileSync(resolve(__dirname, '../.env.example'), 'utf-8');
expect(envExample).toContain('your_vk_service_token_here');
expect(envExample).not.toMatch(/vk1\.[a-zA-Z0-9]/);
});
});

138
tests/vk-errors.test.ts Normal file
View file

@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VkProvider } from '../src/providers/vk/vk-provider';
describe('VK API error handling', () => {
const token = 'vk1.a.test-service-token';
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetchJson(json: unknown, status = 200) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 500 ? 'Internal Server Error' : 'OK',
json: async () => json,
});
}
function mockFetchNetworkError(message = 'Network request failed') {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error(message));
}
it('throws on invalid token (error_code 5)', 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(/User authorization failed/);
});
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/);
});
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/);
});
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);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/Too many requests/);
});
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/);
});
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/);
});
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/);
});
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/);
});
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/);
});
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/);
});
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/);
});
it('throws on HTTP 500 from VK', async () => {
mockFetchJson({}, 500);
const provider = new VkProvider(token);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/HTTP error: 500/);
});
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/);
});
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');
} catch (err: any) {
expect(err.message).not.toContain(token);
}
});
it('does not retry transient errors by default', async () => {
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
const provider = new VkProvider(token);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
expect(global.fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,190 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
import { RawParticipant } from '../src/core/types/participant';
describe('VkMockProvider scenarios', () => {
let provider: VkMockProvider;
beforeEach(() => {
provider = new VkMockProvider();
});
it('default scenario returns 35 participants', async () => {
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants).toHaveLength(35);
expect(participants[0]).toHaveProperty('platformUserId');
expect(participants[0]).toHaveProperty('liked');
});
it('supports 0 participants', async () => {
provider.setScenario({ participantCount: 0 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants).toHaveLength(0);
});
it('supports 1 participant', async () => {
provider.setScenario({ participantCount: 1 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants).toHaveLength(1);
expect(participants[0].platformUserId).toBeDefined();
});
it('supports 10 participants', async () => {
provider.setScenario({ participantCount: 10 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants).toHaveLength(10);
});
it('supports 1000 participants', async () => {
provider.setScenario({ participantCount: 1000 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants).toHaveLength(1000);
// IDs must be unique before deduplication
const ids = new Set(participants.map(p => p.platformUserId));
expect(ids.size).toBe(1000);
});
it('supports large participant counts efficiently', async () => {
provider.setScenario({ participantCount: 50000 });
const start = Date.now();
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const duration = Date.now() - start;
expect(participants).toHaveLength(50000);
expect(duration).toBeLessThan(5000);
});
it('supports likes-only scenario', async () => {
provider.setScenario({ participantCount: 20, likedRatio: 1, commentedRatio: 0 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants.every(p => p.liked)).toBe(true);
expect(participants.every(p => !p.commented)).toBe(true);
});
it('supports comments-only scenario', async () => {
provider.setScenario({ participantCount: 20, likedRatio: 0, commentedRatio: 1 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants.every(p => !p.liked)).toBe(true);
expect(participants.every(p => p.commented)).toBe(true);
});
it('supports mixed likes + comments scenario', async () => {
provider.setScenario({ participantCount: 100, likedRatio: 0.7, commentedRatio: 0.4 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const likedCount = participants.filter(p => p.liked).length;
const commentedCount = participants.filter(p => p.commented).length;
expect(likedCount).toBeGreaterThan(50);
expect(likedCount).toBeLessThan(90);
expect(commentedCount).toBeGreaterThan(20);
expect(commentedCount).toBeLessThan(60);
});
it('checkSubscription returns subscribed / not subscribed deterministically', async () => {
provider.setScenario({ participantCount: 10, subscribedRatio: 0.5 });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const ids = participants.map(p => p.platformUserId);
const map = await provider.checkSubscription(ids, '-100');
let subscribedCount = 0;
ids.forEach(id => {
if (map.get(id)) subscribedCount++;
});
expect(subscribedCount).toBeGreaterThan(0);
expect(subscribedCount).toBeLessThan(10);
});
it('legacy checkSubscription marks users ending in 0 or 5 as not subscribed', async () => {
provider.resetScenario();
// Generated IDs are 1000000 + i*137. i=5 -> 1000685 (ends 5), i=10 -> 1001370 (ends 0).
const map = await provider.checkSubscription(
['1000137', '1000685', '1001370', '1000411'],
'-100'
);
expect(map.get('1000685')).toBe(false); // ends with 5
expect(map.get('1001370')).toBe(false); // ends with 0
expect(map.get('1000137')).toBe(true); // ends with 7
expect(map.get('1000411')).toBe(true); // ends with 1
});
it('allows injecting duplicates via extraParticipants', async () => {
provider.setScenario({
participantCount: 2,
extraParticipants: [
{
platformUserId: '1000137',
firstName: 'Duplicate',
lastName: 'Entry',
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
},
],
});
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
expect(participants).toHaveLength(3);
const dupes = participants.filter(p => p.platformUserId === '1000137');
expect(dupes).toHaveLength(2);
});
it('marks configured adminIds as admins', async () => {
provider.setScenario({ participantCount: 5, adminIds: ['1000137', '1000411'] });
const participants = await provider.fetchParticipants({
ownerId: '-100',
postId: '1',
});
const admin1 = participants.find(p => p.platformUserId === '1000137');
const admin2 = participants.find(p => p.platformUserId === '1000411');
const nonAdmin = participants.find(p => p.platformUserId === '1000274');
expect(admin1?.isAdmin).toBe(true);
expect(admin2?.isAdmin).toBe(true);
expect(nonAdmin?.isAdmin).toBe(false);
});
it('fetchPost returns metadata with parsed ownerId and postId', async () => {
const post = await provider.fetchPost('https://vk.com/wall-123456_789');
expect(post.platform).toBe('VK');
expect(post.ownerId).toBe('-123456');
expect(post.postId).toBe('789');
expect(post.likesCount).toBeGreaterThan(0);
});
it('fetchPost falls back to default ids for unparsable url', async () => {
const post = await provider.fetchPost('not-a-url');
expect(post.ownerId).toBe('-22446688');
expect(post.postId).toBe('1054');
});
});