diff --git a/docs/OPENCODE_REVIEW.md b/docs/OPENCODE_REVIEW.md new file mode 100644 index 0000000..0c90fcb --- /dev/null +++ b/docs/OPENCODE_REVIEW.md @@ -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:** 12–60 +- **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 28–36). + 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:** 24–58 (`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:** 27–30 +- **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 14–35) + - `src/app/api/giveaways/[id]/draw/route.ts` (lines 42–46) + - `src/app/api/giveaways/[id]/snapshot/route.ts` (lines 10–30) + - `src/app/api/posts/preview/route.ts` (lines 5–30) +- **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:** 142–183 (likes), 186–238 (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:** 43–52 (`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 ` 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:** 38–69 (`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 ≈ 200–400 KB JSON. + - ~100 000 participants ≈ 20–40 MB per snapshot row. + - ~500 000 participants ≈ 100–200 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:** 9–12 +- **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:** 19–22 +- **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:** 7–10 +- **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:** 25–42 +- **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:** 24–30 +- **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: **250–400 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. diff --git a/docs/VK_INTEGRATION_PLAN.md b/docs/VK_INTEGRATION_PLAN.md new file mode 100644 index 0000000..e9b7b7e --- /dev/null +++ b/docs/VK_INTEGRATION_PLAN.md @@ -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(method: string, params: Record): Promise>; +} + +type VkResponse = + | { response: T; error?: never } + | { response?: never; error: VkError }; + +interface VkError { + error_code: number; + error_msg: string; +} + +// Ограничение скорости +interface RateLimiter { + acquire(tokenType: VkTokenType, cost?: number): Promise; + updateLimits(tokenType: VkTokenType, remaining: number, resetAt: Date): void; +} + +type VkTokenType = 'service' | 'user' | 'community'; + +// Политика повторных попыток +interface RetryPolicy { + execute(task: () => Promise, context: RetryContext): Promise; +} + +interface RetryContext { + maxAttempts: number; + isRetryable: (error: VkError) => boolean; + computeDelay: (attempt: number, error: VkError) => number; +} +``` + +### Exponential backoff + +- Начальная задержка: 500–1000 мс. +- Множитель: 2 (с jitter до 20–30 %), чтобы избежать «thundering herd». +- Максимальная задержка: 30–60 с. +- Общее время ожидания должно быть ограничено (например, 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: 30–60 с. +- Поддержать `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` и требуют проверки в процессе разработки. diff --git a/src/app/api/giveaways/[id]/participants/route.ts b/src/app/api/giveaways/[id]/participants/route.ts index f43588f..5101acc 100644 --- a/src/app/api/giveaways/[id]/participants/route.ts +++ b/src/app/api/giveaways/[id]/participants/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; import { ProviderRegistry } from '@/providers/registry'; import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher'; +import { validateFilterRulesAgainstProviderCapabilities } from '@/core/filtering/rule-validation'; export async function POST( req: NextRequest, @@ -19,6 +20,15 @@ export async function POST( const rules = body.filterRules || giveaway.filterRules; 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 const rawParticipants = await provider.fetchParticipants({ ownerId: giveaway.platformOwnerId, diff --git a/src/core/filtering/rule-validation.ts b/src/core/filtering/rule-validation.ts new file mode 100644 index 0000000..9d48da2 --- /dev/null +++ b/src/core/filtering/rule-validation.ts @@ -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, + }; +} diff --git a/src/providers/vk/vk-mock-provider.ts b/src/providers/vk/vk-mock-provider.ts index 77dac25..134d8cd 100644 --- a/src/providers/vk/vk-mock-provider.ts +++ b/src/providers/vk/vk-mock-provider.ts @@ -3,6 +3,77 @@ import { RawParticipant } from '../../core/types/participant'; import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types'; 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 { readonly platform: PlatformType = 'VK'; readonly capabilities: ProviderCapabilities = { @@ -15,10 +86,30 @@ export class VkMockProvider implements SocialMediaProvider { adminDetectionNote: 'Требует расширенных прав администратора сообщества', }; + private scenario: MockScenario = { ...DEFAULT_SCENARIO }; + parsePostUrl(url: string): { ownerId: string; postId: string } | null { return parseVkPostUrl(url); } + /** + * Configure the mock scenario. Does not change the public SocialMediaProvider contract. + */ + setScenario(scenario: Partial): 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 { const parsed = this.parsePostUrl(url); const ownerId = parsed ? parsed.ownerId : '-22446688'; @@ -43,51 +134,45 @@ export class VkMockProvider implements SocialMediaProvider { }; } - async fetchParticipants(params: FetchParticipantsParams): Promise { - const mockNames = [ - { 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' }, - ]; + async fetchParticipants(_params: FetchParticipantsParams): Promise { + const { + participantCount, + likedRatio, + commentedRatio, + commentsCount: commentsCountOverride, + adminIds = [], + extraParticipants = [], + } = this.scenario; + + const adminSet = new Set(adminIds.map(id => id.trim())); const participants: RawParticipant[] = []; - for (let i = 1; i <= 35; i++) { - const nameObj = mockNames[(i - 1) % mockNames.length]; + for (let i = 1; i <= participantCount; i++) { + const nameObj = MOCK_NAMES[(i - 1) % MOCK_NAMES.length]; const userId = `${1000000 + i * 137}`; - - const liked = i !== 7 && i !== 19; - const commented = i % 2 === 0 || i % 3 === 0; - const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0; + + const liked = likedRatio !== undefined + ? deterministic01(i * 7 + 1) < likedRatio + : 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 subscribed = false; // will be resolved via checkSubscription - const isAdmin = false; + const subscribed = false; // resolved via checkSubscription + const isAdmin = adminSet.has(userId); participants.push({ 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, username: `${nameObj.user}_${i}`, 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; } - async checkSubscription(userIds: string[], groupId: string): Promise> { + async checkSubscription(userIds: string[], _groupId: string): Promise> { const result = new Map(); + const { subscribedRatio } = this.scenario; + for (const id of userIds) { - // Mock: users ending in 0 or 5 are not subscribed, others are subscribed 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; } } diff --git a/tests/api-validation.test.ts b/tests/api-validation.test.ts new file mode 100644 index 0000000..dd9e01e --- /dev/null +++ b/tests/api-validation.test.ts @@ -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); + }); + }); +}); diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..e1a6f58 --- /dev/null +++ b/tests/concurrency.test.ts @@ -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); + }); +}); diff --git a/tests/filter-engine.test.ts b/tests/filter-engine.test.ts index 05b81d8..2527fa9 100644 --- a/tests/filter-engine.test.ts +++ b/tests/filter-engine.test.ts @@ -194,4 +194,251 @@ describe('Filter Engine', () => { expect(result.stats.eligibleCount).toBe(1); expect(result.allParticipants[0].commentsCount).toBe(3); }); + + describe('rule combination matrix', () => { + function makeParticipant( + id: string, + overrides: Partial = {} + ): 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'); + }); + }); }); diff --git a/tests/participant-pipeline.test.ts b/tests/participant-pipeline.test.ts new file mode 100644 index 0000000..0a5fb93 --- /dev/null +++ b/tests/participant-pipeline.test.ts @@ -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 { + 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(); + 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); + }); +}); diff --git a/tests/provider-capabilities.test.ts b/tests/provider-capabilities.test.ts new file mode 100644 index 0000000..06b8739 --- /dev/null +++ b/tests/provider-capabilities.test.ts @@ -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); + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..4966143 --- /dev/null +++ b/tests/security.test.ts @@ -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]/); + }); +}); diff --git a/tests/vk-errors.test.ts b/tests/vk-errors.test.ts new file mode 100644 index 0000000..89fab32 --- /dev/null +++ b/tests/vk-errors.test.ts @@ -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).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).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).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/vk-mock-provider.test.ts b/tests/vk-mock-provider.test.ts new file mode 100644 index 0000000..fafcb9b --- /dev/null +++ b/tests/vk-mock-provider.test.ts @@ -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'); + }); +});