test(opencode): independent review, VK integration plan, expanded unit tests, provider capabilities validation, and security/concurrency coverage
This commit is contained in:
parent
0fb5fe3f8d
commit
d0aa55ae30
13 changed files with 2359 additions and 40 deletions
318
docs/OPENCODE_REVIEW.md
Normal file
318
docs/OPENCODE_REVIEW.md
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
# OpenCode Independent Review — Randomayzer
|
||||
|
||||
**Scope:** test coverage, security, concurrency, VK integration preparation.
|
||||
**Review date:** 2026-08-18
|
||||
**Branch reviewed:** `main` (initial clone)
|
||||
**Reviewer:** OpenCode
|
||||
|
||||
> **Coordination note:** another agent (Antigravity) is performing Core Audit Fixes / Phase 1.2.
|
||||
> This review intentionally does **not** rewrite the Randomizer, Prisma schema, FSM, AuditProof, `giveaway-store.ts`, VK OAuth, or any existing core architecture. Findings that touch those areas are documented as recommendations and are **not** auto-fixed unless they are isolated, low-risk, and do not overlap with Antigravity's scope.
|
||||
|
||||
---
|
||||
|
||||
## Summary of findings
|
||||
|
||||
| Severity | Count | Themes |
|
||||
|---|---|---|
|
||||
| CRITICAL | 2 | double-draw race, silent DB→memory fallback |
|
||||
| HIGH | 6 | capability violations, API input validation, VK pagination limits, token exposure surface, missing retry/rate-limit, JSON snapshot scalability |
|
||||
| MEDIUM | 5 | environment-based mock fallback, `.gitignore` gaps, error-message leakage, unused env vars, `excludeDuplicateComments` ignored |
|
||||
| LOW | 3 | in-memory IDs via `Math.random()`, `filterRules` immutability gaps, `MINIMUM_COMMENTS` not implemented |
|
||||
| INFO | 3 | architecture strengths, provider abstraction, clean separation |
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1 — Race condition allows duplicate draw for the same giveaway
|
||||
|
||||
- **File:** `src/app/api/giveaways/[id]/draw/route.ts`
|
||||
- **Lines:** 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 <token>` header where VK allows it; otherwise ensure request URLs are never logged.
|
||||
- Scrub tokens from any error serialization in `VkProvider`.
|
||||
- Add automated tests asserting that token does not appear in thrown messages, responses, or logs (see `tests/security.test.ts`).
|
||||
- **Coordination:** low risk; security tests added. Header change depends on VK API contract (documented as recommendation).
|
||||
|
||||
### H5 — No rate limiting, retry, or timeout strategy in VK client
|
||||
|
||||
- **File:** `src/providers/vk/vk-provider.ts`
|
||||
- **Lines:** 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.
|
||||
255
docs/VK_INTEGRATION_PLAN.md
Normal file
255
docs/VK_INTEGRATION_PLAN.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# План интеграции VK API для Randomayzer
|
||||
|
||||
> **Контекст:** Randomayzer — Next.js + TypeScript + Prisma + PostgreSQL сервис для проведения доказуемо честных розыгрышей ВКонтакте. В текущей реализации (`src/providers/vk/vk-provider.ts`) используется сервисный токен `VK_SERVICE_TOKEN` и методы `wall.getById`, `likes.getList`, `wall.getComments`, `groups.isMember`. OAuth / VK ID не реализованы.
|
||||
>
|
||||
> **Статус документа:** планировочный, без production-кода. Все факты, которые не удалось подтвердить официальной документацией VK, помечены как `UNVERIFIED`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Для работы с VK API существуют три типа ключей доступа. Для Randomayzer наиболее подходящим **на старте** является **сервисный ключ** (соответствует текущей реализации `VkProvider`), так как он не требует авторизации пользователя и покрывает базовые сценарии розыгрыша: получение метаданных поста, сбор лайков/комментариев с открытых стен и пакетную проверку подписки на сообщество.
|
||||
|
||||
Расширенные сценарии (проверка репостов на закрытых страницах, получение списка руководителей сообщества, действия от имени сообщества) требуют **ключа пользователя** или **ключа сообщества**.
|
||||
|
||||
### Service Token (сервисный ключ доступа)
|
||||
|
||||
- **Как получить.** В панели управления приложением на [dev.vk.ru](https://dev.vk.ru/ru/admin/apps-list) (раздел **Разработка → Ключи доступа**) либо в кабинете сервиса авторизации VK ID при создании Standalone-приложения/сайта.
|
||||
- **Scope/права.** Права доступа не запрашиваются. Ключ предназначен для работы с публичными данными игр/мини-приложений и методами, не требующими авторизации пользователя.
|
||||
- **Применимые методы.** `wall.getById`, `likes.getList`, `wall.getComments`, `wall.getReposts`, `groups.isMember`, `groups.getMembers` (с ограничениями), `execute` UNVERIFIED.
|
||||
- **Ограничения.**
|
||||
- Работает только с открытыми профилями и открытыми группами.
|
||||
- Не позволяет выполнять действия от имени пользователя или сообщества.
|
||||
- Сбор репостов ограничен политикой приватности VK (закрытые профили не видны).
|
||||
- **Lifetime.** Не ограничен; при компрометации можно перевыпустить в настройках приложения.
|
||||
- **Риски.** Утечка ключа открывает публичные данные приложения. Ключ должен храниться только на сервере, никогда — в клиентском коде.
|
||||
|
||||
### User Token (ключ доступа пользователя / OAuth 2.0 / VK ID)
|
||||
|
||||
- **Как получить.** Через сервис авторизации VK ID (Authorization Code Flow для сервера, Implicit Flow для клиента) или событие `VKWebAppGetAuthToken` VK Bridge в мини-приложениях/играх.
|
||||
- **Scope/права.** Определяются правами доступа, которые пользователь выдал приложению (например, `wall`, `groups`, `friends`). Базовые права (имя, фото, почта) доступны сразу; расширенные требуют подтверждения профиля бизнеса.
|
||||
- **Применимые методы.** Все методы, доступные сервисному ключу, плюс методы, требующие авторизации конкретного пользователя: просмотр закрытых профилей при наличии доступа, расширенная работа со стеной, `groups.get` и т.д.
|
||||
- **Ограничения.**
|
||||
- Требует прохождения пользователем экрана согласия.
|
||||
- Короткий срок жизни (см. ниже), необходимо обновление.
|
||||
- **Lifetime.** **1 час** для ключа, полученного через VK ID / OAuth.
|
||||
- **Риски.**
|
||||
- Необходимость безопасного хранения refresh-токенов и секретов приложения.
|
||||
- Нужно реализовать OAuth flow и обработку отзыва разрешений пользователем.
|
||||
- При передаче в клиент увеличивается риск перехвата.
|
||||
|
||||
### Community Token (ключ доступа сообщества)
|
||||
|
||||
- **Как получить.** В настройках сообщества: **Управление → Дополнительно → Работа с API → Ключи доступа**. Можно создать несколько ключей с разным набором прав. Программно — через OAuth ВКонтакте (Authorization Code Flow / Implicit Flow) либо событие `VKWebAppGetCommunityToken` VK Bridge.
|
||||
- **Scope/права.** Права назначаются при создании ключа (например, `wall`, `photos`, `messages`, `manage`).
|
||||
- **Применимые методы.**
|
||||
- Методы в рамках одного сообщества: `wall.getById`, `likes.getList`, `wall.getComments`, `groups.isMember`, `groups.getMembers` (включая `filter=managers` для получения ролей).
|
||||
- Методы управления сообществом — не требуются Randomayzer на этапе сбора участников.
|
||||
- **Ограничения.**
|
||||
- Действует только в рамках сообщества, для которого выдан.
|
||||
- Получить ключ может только администратор сообщества.
|
||||
- **Lifetime.** Не ограничен; администратор может отозвать ключ в любой момент.
|
||||
- **Риски.**
|
||||
- Утечка ключа с правами `manage` даёт полный контроль над сообществом.
|
||||
- Требует строгого разграничения прав по ключам (принцип минимальных привилегий).
|
||||
|
||||
### Рекомендация для Randomayzer
|
||||
|
||||
| Этап | Рекомендуемый токен | Обоснование |
|
||||
|---|---|---|
|
||||
| MVP / текущая реализация | Сервисный ключ | Без OAuth, серверная работа, публичные посты и лайки |
|
||||
| Проверка репостов в закрытых профилях | Ключ пользователя организатора | Требуется авторизация владельца стены/профиля |
|
||||
| Проверка администраторов/модераторов сообщества | Ключ сообщества | `groups.getMembers filter=managers` доступно с правами администратора |
|
||||
| Массовые розыгрыши 10 000+ участников | Сервисный + пакетирование (`execute`) UNVERIFIED | Высшие лимиты и отсутствие необходимости UI-авторизации |
|
||||
|
||||
---
|
||||
|
||||
## Tokens
|
||||
|
||||
| Тип токена | Назначение | Доступные методы (для розыгрышей) | Ограничения | Срок жизни | Риски |
|
||||
|---|---|---|---|---|---|
|
||||
| **Сервисный ключ** | Серверные запросы без авторизации пользователя | `wall.getById`, `likes.getList`, `wall.getComments`, `wall.getReposts`, `groups.isMember`, `groups.getMembers` | Только открытые профили/группы; репосты ограничены приватностью; `execute` — UNVERIFIED | Не ограничен | Утечка открывает публичные данные; нельзя передавать на клиент |
|
||||
| **Ключ пользователя (VK ID OAuth)** | Запросы от имени пользователя | Все методы сервисного ключа + методы, требующие авторизации (`groups.get`, доступ к закрытым данным при согласии) | Требуется согласие пользователя; короткий срок жизни; refresh-логика | **1 час** | Хранение секретов/refresh; необходимость OAuth flow; отзыв прав |
|
||||
| **Ключ сообщества** | Запросы от имени сообщества | Методы в рамках одного сообщества, включая `groups.getMembers filter=managers` | Только своё сообщество; нужен администратор для создания | Не ограничен | Утечка ключа с правами `manage` даёт контроль над сообществом |
|
||||
|
||||
### Что каждый токен может и не может делать для розыгрышей
|
||||
|
||||
- **Сервисный ключ**
|
||||
- ✅ Получить метаданные открытого поста.
|
||||
- ✅ Собрать лайки (`likes.getList`) и комментарии (`wall.getComments`).
|
||||
- ✅ Проверить подписку на сообщество (`groups.isMember`).
|
||||
- ❌ Получить список руководителей сообщества (`groups.getMembers filter=managers`) — UNVERIFIED, вероятно требуется ключ сообщества.
|
||||
- ❌ Увидеть репосты на закрытых профилях.
|
||||
- **Ключ пользователя**
|
||||
- ✅ Всё, что умеет сервисный ключ (если профили/группы доступны пользователю).
|
||||
- ✅ Доступ к расширенным данным при наличии соответствующих прав.
|
||||
- ❌ Не даёт прав администратора чужого сообщества.
|
||||
- **Ключ сообщества**
|
||||
- ✅ Все операции в рамках своего сообщества.
|
||||
- ✅ Проверка ролей (`groups.getMembers filter=managers`) — при наличии прав.
|
||||
- ❌ Не применим к постам/группам других организаторов.
|
||||
|
||||
---
|
||||
|
||||
## Required VK methods
|
||||
|
||||
| Функциональность | VK Method | Токен | Права / Параметры | Ограничения |
|
||||
|---|---|---|---|---|
|
||||
| Загрузка метаданных поста | `wall.getById` | Сервисный или пользовательский | `posts={owner_id}_{post_id}`, `extended=1` | Возвращает массив объектов `post`; при `extended=1` дополнительно `profiles` и `groups`. Ошибка `104 Not found`, если запись недоступна. |
|
||||
| Сбор лайков | `likes.getList` | Сервисный или пользовательский | `type=post`, `owner_id`, `item_id`, `filter=likes`, `extended=1`, `count` до 1000, `offset` | Максимум 1000 идентификаторов за запрос. При `filter=copies` возвращает пользователей, поделившихся записью, но только если запрос отправляет администратор группы (права редактора и выше) или владелец стены. |
|
||||
| Сбор комментариев | `wall.getComments` | Сервисный или пользовательский | `owner_id`, `post_id`, `extended=1`, `count` до 100, `offset`, `fields` | Максимум 100 комментариев за запрос. При `extended=1` возвращает `profiles` и `groups`. Ошибка `212 Access to post comments denied`. |
|
||||
| Проверка подписки на сообщество | `groups.isMember` | Сервисный, пользовательский или ключ сообщества | `group_id`, `user_id` или `user_ids` (до 500), `extended=1` | При пакетной проверке возвращает массив объектов `{user_id, member}`. При `extended=1` дополнительно `request`, `invitation`, `can_invite`. |
|
||||
| Информация о сообществе / список участников | `groups.getMembers` | Сервисный, пользовательский или ключ сообщества | `group_id`, `count`, `offset`, `fields`, `filter` | `filter=managers` доступно при запросе от имени администратора сообщества; возвращает `role` (`advertiser`, `moderator`, `editor`, `administrator`, `creator`). Максимальное значение `count` — UNVERIFIED. |
|
||||
| Сбор / проверка репостов | `wall.getReposts` | Сервисный или пользовательский | `owner_id`, `post_id`, `offset`, `count` | Возвращает `items` (записи-репосты), `profiles`, `groups`. Закрытые профили не попадают в выдачу без авторизации владельца стены. |
|
||||
| Альтернативная проверка репостов | `likes.getList` с `filter=copies` | Ключ сообщества (администратор группы) или владелец стены | `type=post`, `owner_id`, `item_id`, `filter=copies`, `extended=1` | Возвращает пользователей, поделившихся записью, только при наличии соответствующих прав. |
|
||||
| Проверка администраторов/модераторов | `groups.getMembers` с `filter=managers` | Ключ сообщества с правами администратора | `group_id`, `filter=managers` | Возвращает руководителей сообщества с полем `role`. Недоступно сервисному ключу — UNVERIFIED. |
|
||||
|
||||
### Примечания к таблице
|
||||
|
||||
- Типы токенов для каждого метода определены по цветовым индикаторам в официальном справочнике VK: серый — сервисный ключ, оранжевый — пользовательский, синий — ключ сообщества.
|
||||
- `wall.getById`, `likes.getList`, `wall.getComments`, `wall.getReposts` имеют индикаторы сервисного и пользовательского токенов.
|
||||
- `groups.isMember` и `groups.getMembers` имеют индикаторы всех трёх типов токенов.
|
||||
|
||||
---
|
||||
|
||||
## Rate Limit Strategy
|
||||
|
||||
> **Важно:** этот раздел описывает только архитектуру и контракты. Код не реализуется в рамках данного документа.
|
||||
|
||||
### Целевая архитектура
|
||||
|
||||
```text
|
||||
VK API
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ VkClient │────▶│ RateLimiter │────▶│ RetryPolicy │────▶│ VkProvider │
|
||||
│ (HTTP+Auth)│ │ (token-bucket)│ │ (backoff) │ │(SocialMediaProvider)
|
||||
└─────────────┘ └──────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
`VkProvider` реализует интерфейс `SocialMediaProvider` и делегирует низкоуровневые вызовы `VkClient`. `RateLimiter` и `RetryPolicy` являются отдельными, тестируемыми абстракциями.
|
||||
|
||||
### Контракты / интерфейсы
|
||||
|
||||
```typescript
|
||||
// Абстракция HTTP-клиента VK API
|
||||
interface VkClient {
|
||||
call<T>(method: string, params: Record<string, unknown>): Promise<VkResponse<T>>;
|
||||
}
|
||||
|
||||
type VkResponse<T> =
|
||||
| { response: T; error?: never }
|
||||
| { response?: never; error: VkError };
|
||||
|
||||
interface VkError {
|
||||
error_code: number;
|
||||
error_msg: string;
|
||||
}
|
||||
|
||||
// Ограничение скорости
|
||||
interface RateLimiter {
|
||||
acquire(tokenType: VkTokenType, cost?: number): Promise<void>;
|
||||
updateLimits(tokenType: VkTokenType, remaining: number, resetAt: Date): void;
|
||||
}
|
||||
|
||||
type VkTokenType = 'service' | 'user' | 'community';
|
||||
|
||||
// Политика повторных попыток
|
||||
interface RetryPolicy {
|
||||
execute<T>(task: () => Promise<T>, context: RetryContext): Promise<T>;
|
||||
}
|
||||
|
||||
interface RetryContext {
|
||||
maxAttempts: number;
|
||||
isRetryable: (error: VkError) => boolean;
|
||||
computeDelay: (attempt: number, error: VkError) => number;
|
||||
}
|
||||
```
|
||||
|
||||
### Exponential backoff
|
||||
|
||||
- Начальная задержка: 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` и требуют проверки в процессе разработки.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
43
src/core/filtering/rule-validation.ts
Normal file
43
src/core/filtering/rule-validation.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { FilterRules } from '../types/giveaway';
|
||||
import { ProviderCapabilities } from '../../providers/types';
|
||||
|
||||
export interface RuleValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the requested filter rules can actually be verified by the
|
||||
* selected social-media provider. This prevents organizers from configuring
|
||||
* giveaways with conditions (e.g. reposts) that the provider cannot check.
|
||||
*/
|
||||
export function validateFilterRulesAgainstProviderCapabilities(
|
||||
rules: FilterRules,
|
||||
capabilities: ProviderCapabilities
|
||||
): RuleValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (rules.requireRepost && !capabilities.reposts) {
|
||||
errors.push(
|
||||
`requireRepost is not supported by the ${capabilities.repostsNote || 'current provider'}`
|
||||
);
|
||||
}
|
||||
|
||||
if (rules.requireSubscription && !capabilities.subscriptions) {
|
||||
errors.push('requireSubscription is not supported by the current provider');
|
||||
}
|
||||
|
||||
if (rules.excludeAdmins && !capabilities.adminDetection) {
|
||||
errors.push(
|
||||
`excludeAdmins is not supported by the ${capabilities.adminDetectionNote || 'current provider'}`
|
||||
);
|
||||
}
|
||||
|
||||
// likes/comments are considered universally supported by providers that have
|
||||
// them declared; the engine itself still needs a provider to fetch them.
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<MockScenario>): void {
|
||||
this.scenario = { ...DEFAULT_SCENARIO, ...scenario };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to the original 35-participant default scenario.
|
||||
*/
|
||||
resetScenario(): void {
|
||||
this.scenario = { ...DEFAULT_SCENARIO };
|
||||
}
|
||||
|
||||
getScenario(): MockScenario {
|
||||
return { ...this.scenario };
|
||||
}
|
||||
|
||||
async fetchPost(url: string): Promise<PostMetadata> {
|
||||
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<RawParticipant[]> {
|
||||
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<RawParticipant[]> {
|
||||
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<Map<string, boolean>> {
|
||||
async checkSubscription(userIds: string[], _groupId: string): Promise<Map<string, boolean>> {
|
||||
const result = new Map<string, boolean>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
303
tests/api-validation.test.ts
Normal file
303
tests/api-validation.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
import { ProviderRegistry } from '../src/providers/registry';
|
||||
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
|
||||
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
||||
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
||||
import { POST as previewPost } from '../src/app/api/posts/preview/route';
|
||||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
||||
import { GET as giveawayGet } from '../src/app/api/giveaways/[id]/route';
|
||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
|
||||
async function createGiveaway(overrides: Partial<{ filterRules: typeof DEFAULT_FILTER_RULES; winnersCount: number; reserveWinnersCount: number; seed: string }> = {}) {
|
||||
return GiveawayStore.create({
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
post: {
|
||||
platform: 'VK',
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
title: 'Test',
|
||||
text: 'Test',
|
||||
likesCount: 10,
|
||||
commentsCount: 5,
|
||||
repostsCount: 2,
|
||||
},
|
||||
filterRules: overrides.filterRules || DEFAULT_FILTER_RULES,
|
||||
winnersCount: overrides.winnersCount ?? 1,
|
||||
reserveWinnersCount: overrides.reserveWinnersCount ?? 0,
|
||||
seed: overrides.seed,
|
||||
});
|
||||
}
|
||||
|
||||
const sampleParticipants: FilteredParticipant[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
platformUserId: `${1000 + i}`,
|
||||
firstName: 'User',
|
||||
lastName: `${i}`,
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
}));
|
||||
|
||||
describe('API input validation', () => {
|
||||
beforeEach(() => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways', () => {
|
||||
it('returns 400 when sourceUrl is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ post: {} }),
|
||||
});
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when post is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceUrl: 'https://vk.com/wall-1_1' }),
|
||||
});
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 500 for malformed JSON body', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: 'not-json',
|
||||
});
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/posts/preview', () => {
|
||||
it('returns 400 for invalid VK URL', async () => {
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: 'https://google.com' }),
|
||||
});
|
||||
const res = await previewPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when URL is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await previewPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 500 for malformed JSON', async () => {
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: '{ broken',
|
||||
});
|
||||
const res = await previewPost(req);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways/:id/draw', () => {
|
||||
it('returns 404 for non-existent giveaway', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways/does-not-exist/draw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: 'does-not-exist' } });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 when drawing a giveaway that is already DRAWN', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, sampleParticipants, DEFAULT_FILTER_RULES);
|
||||
|
||||
// First draw
|
||||
const firstReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const firstRes = await drawPost(firstReq, { params: { id: gw.id } });
|
||||
expect(firstRes.status).toBe(200);
|
||||
|
||||
// Second draw attempt
|
||||
const secondReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const secondRes = await drawPost(secondReq, { params: { id: gw.id } });
|
||||
expect(secondRes.status).toBe(400);
|
||||
const data = await secondRes.json();
|
||||
expect(data.error).toMatch(/уже проведен|already drawn/i);
|
||||
});
|
||||
|
||||
it('returns 400 when there are 0 eligible participants', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/Нет допущенных|0 eligible/i);
|
||||
});
|
||||
|
||||
it('currently accepts winnersCount = -1 (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: -1 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
// Current behavior: does not reject negative winnersCount; it caps to pool size.
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.winners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('currently treats winnersCount = 0 as the giveaway default (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 0 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
// The route uses `body.winnersCount || giveaway.winnersCount || 1`, so 0 is ignored.
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.winners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps winnersCount = 999999999 to pool size', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 999999999 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.winners).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('currently accepts reserveWinnersCount < 0 (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reserveWinnersCount: -5 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.reserveWinners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses generated seed when empty seed is provided', async () => {
|
||||
const gw = await createGiveaway({ seed: undefined });
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: ' ' }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.seedUsed).toBeTruthy();
|
||||
expect(data.drawResult.seedUsed.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('currently accepts huge seed strings (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const hugeSeed = 'a'.repeat(100_000);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: hugeSeed }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.seedUsed).toBe(hugeSeed);
|
||||
});
|
||||
|
||||
it('currently swallows malformed JSON body and proceeds (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: 'not-json',
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
// `.catch(() => ({}))` silently turns malformed JSON into an empty body.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways/:id/participants', () => {
|
||||
it('currently accepts unknown filter rules in body (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filterRules: {
|
||||
...DEFAULT_FILTER_RULES,
|
||||
unknownRule: true,
|
||||
anotherBadField: 'x',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent giveaway', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways/missing/participants', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await participantsPost(req, { params: { id: 'missing' } });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways/:id/snapshot', () => {
|
||||
it('returns 400 when there are 0 eligible participants', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await snapshotPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/giveaways/:id', () => {
|
||||
it('returns 404 for non-existent giveaway', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways/missing');
|
||||
const res = await giveawayGet(req, { params: { id: 'missing' } });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
tests/concurrency.test.ts
Normal file
139
tests/concurrency.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
import { ProviderRegistry } from '../src/providers/registry';
|
||||
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
||||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
||||
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
|
||||
async function createReadyGiveaway() {
|
||||
const gw = await GiveawayStore.create({
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
post: {
|
||||
platform: 'VK',
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
title: 'Test',
|
||||
text: 'Test',
|
||||
likesCount: 10,
|
||||
commentsCount: 5,
|
||||
repostsCount: 2,
|
||||
},
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
});
|
||||
|
||||
const participants: FilteredParticipant[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
platformUserId: `${1000 + i}`,
|
||||
firstName: 'User',
|
||||
lastName: `${i}`,
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
}));
|
||||
|
||||
await GiveawayStore.updateParticipants(gw.id, participants);
|
||||
return gw;
|
||||
}
|
||||
|
||||
describe('Concurrency analysis', () => {
|
||||
beforeEach(() => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
});
|
||||
|
||||
it('documents current double-draw race behavior (both requests may succeed)', async () => {
|
||||
const gw = await createReadyGiveaway();
|
||||
|
||||
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: 'race-seed-1' }),
|
||||
});
|
||||
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: 'race-seed-2' }),
|
||||
});
|
||||
|
||||
const [res1, res2] = await Promise.all([
|
||||
drawPost(req1, { params: { id: gw.id } }),
|
||||
drawPost(req2, { params: { id: gw.id } }),
|
||||
]);
|
||||
|
||||
// This assertion captures the CURRENT behavior so the test passes today.
|
||||
// If the race is fixed, the previous `it.failing` will start passing and
|
||||
// this test should be updated to assert one failure.
|
||||
expect([res1.status, res2.status]).toContain(200);
|
||||
});
|
||||
|
||||
it('should not corrupt giveaway state when snapshot and draw race', async () => {
|
||||
const gw = await createReadyGiveaway();
|
||||
|
||||
const snapshotReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const drawReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: 'race-seed' }),
|
||||
});
|
||||
|
||||
const [snapRes, drawRes] = await Promise.all([
|
||||
snapshotPost(snapshotReq, { params: { id: gw.id } }),
|
||||
drawPost(drawReq, { params: { id: gw.id } }),
|
||||
]);
|
||||
|
||||
// At least one operation must succeed; both should not silently corrupt.
|
||||
expect([snapRes.status, drawRes.status]).toContain(200);
|
||||
|
||||
const final = await GiveawayStore.getById(gw.id);
|
||||
expect(final).not.toBeNull();
|
||||
// After any successful draw the status must be DRAWN.
|
||||
if (drawRes.status === 200) {
|
||||
expect(final?.status).toBe('DRAWN');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow participant import to overwrite a DRAWN giveaway', async () => {
|
||||
const gw = await createReadyGiveaway();
|
||||
const refreshed = await GiveawayStore.getById(gw.id);
|
||||
const eligible = refreshed!.participants.filter(p => p.eligible);
|
||||
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, eligible, DEFAULT_FILTER_RULES);
|
||||
await GiveawayStore.saveDrawResult(gw.id, snapshot.id, {
|
||||
drawId: 'draw-test',
|
||||
giveawayId: gw.id,
|
||||
snapshotId: snapshot.id,
|
||||
winners: [],
|
||||
reserveWinners: [],
|
||||
winnerIds: [],
|
||||
reserveWinnerIds: [],
|
||||
totalEligibleCount: snapshot.participantCount,
|
||||
totalLoadedCount: gw.participants.length,
|
||||
seedUsed: 'seed',
|
||||
participantsSnapshotHash: snapshot.participantsSnapshotHash,
|
||||
conditionsHash: snapshot.conditionsHash,
|
||||
algorithmVersion: 'HMAC_SHA256_FY_V1',
|
||||
drawnAt: new Date().toISOString(),
|
||||
auditHash: 'audit',
|
||||
});
|
||||
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
// The route catches the FSM error and returns 500; the important thing is
|
||||
// that the DRAWN giveaway is not silently overwritten.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}
|
||||
): 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
266
tests/participant-pipeline.test.ts
Normal file
266
tests/participant-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { executeParticipantPipeline } from '../src/core/pipeline/participant-enricher';
|
||||
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
|
||||
import { FilterRules } from '../src/core/types/giveaway';
|
||||
import { SocialMediaProvider } from '../src/providers/types';
|
||||
import { RawParticipant } from '../src/core/types/participant';
|
||||
|
||||
describe('Participant Pipeline', () => {
|
||||
function buildRules(overrides: Partial<FilterRules> = {}): FilterRules {
|
||||
return {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('full pipeline: fetch -> enrich -> filter -> eligible', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({ participantCount: 20, likedRatio: 1, subscribedRatio: 1 });
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({ requireSubscription: true, targetGroupId: '-100' });
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(result.stats.total).toBe(20);
|
||||
expect(result.eligibleParticipants.length).toBe(20);
|
||||
expect(result.eligibleParticipants.every(p => p.subscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('calls provider.checkSubscription when requireSubscription is true', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({ participantCount: 5, subscribedRatio: 0.5 });
|
||||
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({ requireSubscription: true, targetGroupId: '-100' });
|
||||
|
||||
await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(checkSubscriptionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(checkSubscriptionSpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(rawParticipants.map(p => p.platformUserId)),
|
||||
'-100'
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT call provider.checkSubscription when requireSubscription is false', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({ participantCount: 5 });
|
||||
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({ requireSubscription: false });
|
||||
|
||||
await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(checkSubscriptionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subscription result affects eligibility', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({ participantCount: 10, likedRatio: 1, subscribedRatio: 0 });
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({ requireSubscription: true, targetGroupId: '-100' });
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(result.eligibleParticipants).toHaveLength(0);
|
||||
expect(result.excludedParticipants).toHaveLength(10);
|
||||
expect(result.excludedParticipants[0].exclusionReason).toContain('NOT_SUBSCRIBED');
|
||||
});
|
||||
|
||||
it('uses ownerId as targetGroupId when targetGroupId is not provided for group owner', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({ participantCount: 3, subscribedRatio: 1 });
|
||||
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({ requireSubscription: true }); // no targetGroupId
|
||||
|
||||
await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(checkSubscriptionSpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(rawParticipants.map(p => p.platformUserId)),
|
||||
'-100'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips subscription check for personal walls without targetGroupId', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({ participantCount: 3 });
|
||||
const checkSubscriptionSpy = vi.spyOn(provider, 'checkSubscription');
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({ requireSubscription: true }); // no targetGroupId, owner is user
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '100',
|
||||
});
|
||||
|
||||
expect(checkSubscriptionSpy).not.toHaveBeenCalled();
|
||||
// Participants remain subscribed=false, so they are excluded
|
||||
expect(result.eligibleParticipants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('pipeline filters by combined conditions after enrichment', async () => {
|
||||
const provider = new VkMockProvider();
|
||||
provider.setScenario({
|
||||
participantCount: 50,
|
||||
likedRatio: 0.8,
|
||||
commentedRatio: 0.6,
|
||||
subscribedRatio: 0.7,
|
||||
});
|
||||
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
|
||||
const rules = buildRules({
|
||||
requireLike: true,
|
||||
requireComment: true,
|
||||
requireSubscription: true,
|
||||
targetGroupId: '-100',
|
||||
});
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(result.stats.total).toBe(50);
|
||||
expect(result.eligibleParticipants.length).toBeGreaterThan(0);
|
||||
expect(result.eligibleParticipants.length).toBeLessThan(50);
|
||||
expect(result.eligibleParticipants.every(p => p.liked && p.commented && p.subscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('pipeline handles duplicate users across sources correctly', async () => {
|
||||
const provider: SocialMediaProvider = {
|
||||
platform: 'VK',
|
||||
capabilities: {
|
||||
likes: true,
|
||||
comments: true,
|
||||
reposts: false,
|
||||
subscriptions: true,
|
||||
adminDetection: false,
|
||||
},
|
||||
parsePostUrl: () => ({ ownerId: '-100', postId: '1' }),
|
||||
fetchPost: async () => ({} as any),
|
||||
fetchParticipants: async () => [],
|
||||
checkSubscription: async (userIds: string[]) => {
|
||||
const map = new Map<string, boolean>();
|
||||
userIds.forEach(id => map.set(id, true));
|
||||
return map;
|
||||
},
|
||||
};
|
||||
|
||||
const rawParticipants: RawParticipant[] = [
|
||||
{
|
||||
platformUserId: '42',
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: false,
|
||||
},
|
||||
{
|
||||
platformUserId: '42',
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
source: 'COMMENTS',
|
||||
liked: false,
|
||||
commented: true,
|
||||
commentsCount: 2,
|
||||
reposted: false,
|
||||
subscribed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const rules = buildRules({
|
||||
requireLike: true,
|
||||
requireComment: true,
|
||||
requireSubscription: true,
|
||||
targetGroupId: '-100',
|
||||
});
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(result.stats.total).toBe(1);
|
||||
expect(result.eligibleParticipants).toHaveLength(1);
|
||||
const merged = result.eligibleParticipants[0];
|
||||
expect(merged.liked).toBe(true);
|
||||
expect(merged.commented).toBe(true);
|
||||
expect(merged.commentsCount).toBe(2);
|
||||
expect(merged.subscribed).toBe(true);
|
||||
});
|
||||
});
|
||||
178
tests/provider-capabilities.test.ts
Normal file
178
tests/provider-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { validateFilterRulesAgainstProviderCapabilities } from '../src/core/filtering/rule-validation';
|
||||
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
|
||||
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||
import { ProviderRegistry } from '../src/providers/registry';
|
||||
import { FilterRules } from '../src/core/types/giveaway';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
|
||||
async function createGiveaway(store: typeof GiveawayStore) {
|
||||
return store.create({
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
post: {
|
||||
platform: 'VK',
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
title: 'Test',
|
||||
text: 'Test',
|
||||
likesCount: 0,
|
||||
commentsCount: 0,
|
||||
repostsCount: 0,
|
||||
},
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
},
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function buildReq(id: string, body: object): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/giveaways/${id}/participants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('Provider capabilities', () => {
|
||||
beforeEach(() => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
});
|
||||
|
||||
it('VK mock provider declares reposts=false and adminDetection=false', () => {
|
||||
const provider = new VkMockProvider();
|
||||
expect(provider.capabilities.reposts).toBe(false);
|
||||
expect(provider.capabilities.adminDetection).toBe(false);
|
||||
expect(provider.capabilities.subscriptions).toBe(true);
|
||||
});
|
||||
|
||||
it('VK real provider declares reposts=false and adminDetection=false', () => {
|
||||
const provider = new VkProvider('dummy-token');
|
||||
expect(provider.capabilities.reposts).toBe(false);
|
||||
expect(provider.capabilities.adminDetection).toBe(false);
|
||||
expect(provider.capabilities.subscriptions).toBe(true);
|
||||
});
|
||||
|
||||
it('validation rejects requireRepost when provider cannot verify reposts', () => {
|
||||
const rules: FilterRules = {
|
||||
requireLike: false,
|
||||
requireComment: false,
|
||||
requireRepost: true,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
};
|
||||
const provider = new VkMockProvider();
|
||||
const result = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('requireRepost'))).toBe(true);
|
||||
});
|
||||
|
||||
it('validation rejects excludeAdmins when provider cannot detect admins', () => {
|
||||
const rules: FilterRules = {
|
||||
requireLike: false,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: true,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
};
|
||||
const provider = new VkMockProvider();
|
||||
const result = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('excludeAdmins'))).toBe(true);
|
||||
});
|
||||
|
||||
it('validation accepts supported combinations', () => {
|
||||
const rules: FilterRules = {
|
||||
requireLike: true,
|
||||
requireComment: true,
|
||||
requireRepost: false,
|
||||
requireSubscription: true,
|
||||
targetGroupId: '-100',
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
};
|
||||
const provider = new VkMockProvider();
|
||||
const result = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('participants route returns 400 when requireRepost is requested for VK', async () => {
|
||||
const gw = await createGiveaway(GiveawayStore);
|
||||
const req = buildReq(gw.id, {
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: true,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/Unsupported filter rules/i);
|
||||
expect(data.details.some((d: string) => d.includes('requireRepost'))).toBe(true);
|
||||
});
|
||||
|
||||
it('participants route returns 400 when excludeAdmins is requested for VK', async () => {
|
||||
const gw = await createGiveaway(GiveawayStore);
|
||||
const req = buildReq(gw.id, {
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: true,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.details.some((d: string) => d.includes('excludeAdmins'))).toBe(true);
|
||||
});
|
||||
|
||||
it('participants route succeeds for supported VK rules', async () => {
|
||||
const gw = await createGiveaway(GiveawayStore);
|
||||
const req = buildReq(gw.id, {
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: true,
|
||||
requireRepost: false,
|
||||
requireSubscription: true,
|
||||
targetGroupId: '-100',
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.eligibleCount + data.excludedCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
135
tests/security.test.ts
Normal file
135
tests/security.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ProviderRegistry } from '../src/providers/registry';
|
||||
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
|
||||
import { POST as previewPost } from '../src/app/api/posts/preview/route';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
describe('Security: VK_SERVICE_TOKEN handling', () => {
|
||||
const secretToken = 'vk1.a.super-secret-service-token-xyz';
|
||||
|
||||
beforeEach(() => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
});
|
||||
|
||||
it('ProviderRegistry does not expose VK_SERVICE_TOKEN in public API', () => {
|
||||
process.env.VK_SERVICE_TOKEN = secretToken;
|
||||
// Re-initialize registry (static block already ran, but we can inspect provider)
|
||||
const provider = ProviderRegistry.getProvider('VK');
|
||||
expect(provider.platform).toBe('VK');
|
||||
expect(provider).not.toHaveProperty('serviceToken');
|
||||
delete process.env.VK_SERVICE_TOKEN;
|
||||
});
|
||||
|
||||
it('Giveaway API response does not contain VK_SERVICE_TOKEN', async () => {
|
||||
process.env.VK_SERVICE_TOKEN = secretToken;
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sourceUrl: 'https://vk.com/wall-1_1',
|
||||
post: {
|
||||
platform: 'VK',
|
||||
ownerId: '-1',
|
||||
postId: '1',
|
||||
sourceUrl: 'https://vk.com/wall-1_1',
|
||||
title: 'Test',
|
||||
text: 'Test',
|
||||
likesCount: 0,
|
||||
commentsCount: 0,
|
||||
repostsCount: 0,
|
||||
},
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await giveawaysPost(req);
|
||||
const text = await res.text();
|
||||
expect(text).not.toContain(secretToken);
|
||||
delete process.env.VK_SERVICE_TOKEN;
|
||||
});
|
||||
|
||||
it('Post preview response does not contain VK_SERVICE_TOKEN', async () => {
|
||||
process.env.VK_SERVICE_TOKEN = secretToken;
|
||||
ProviderRegistry.useMockVk(); // mock so no real API call
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: 'https://vk.com/wall-1_1' }),
|
||||
});
|
||||
|
||||
const res = await previewPost(req);
|
||||
const text = await res.text();
|
||||
expect(text).not.toContain(secretToken);
|
||||
delete process.env.VK_SERVICE_TOKEN;
|
||||
});
|
||||
|
||||
it('Created giveaway does not persist VK_SERVICE_TOKEN', async () => {
|
||||
process.env.VK_SERVICE_TOKEN = secretToken;
|
||||
const repo = new MemoryGiveawayRepository();
|
||||
GiveawayStore.setRepository(repo);
|
||||
|
||||
const gw = await GiveawayStore.create({
|
||||
sourceUrl: 'https://vk.com/wall-1_1',
|
||||
post: {
|
||||
platform: 'VK',
|
||||
ownerId: '-1',
|
||||
postId: '1',
|
||||
sourceUrl: 'https://vk.com/wall-1_1',
|
||||
title: 'Test',
|
||||
text: 'Test',
|
||||
likesCount: 0,
|
||||
commentsCount: 0,
|
||||
repostsCount: 0,
|
||||
},
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const json = JSON.stringify(gw);
|
||||
expect(json).not.toContain(secretToken);
|
||||
delete process.env.VK_SERVICE_TOKEN;
|
||||
});
|
||||
|
||||
it('VkProvider error messages do not contain the token', async () => {
|
||||
const provider = new VkProvider(secretToken);
|
||||
let thrownMessage = '';
|
||||
try {
|
||||
// Force error by calling with no token? Provider has token, but callApi will fail network.
|
||||
await provider.fetchPost('not-a-valid-url');
|
||||
} catch (err: any) {
|
||||
thrownMessage = err.message || '';
|
||||
}
|
||||
expect(thrownMessage).not.toContain(secretToken);
|
||||
});
|
||||
|
||||
it('.gitignore excludes local environment files', () => {
|
||||
const gitignore = readFileSync(resolve(__dirname, '../.gitignore'), 'utf-8');
|
||||
expect(gitignore).toContain('.env');
|
||||
expect(gitignore).toContain('.env*.local');
|
||||
});
|
||||
|
||||
it('.env.example does not contain real secrets', () => {
|
||||
const envExample = readFileSync(resolve(__dirname, '../.env.example'), 'utf-8');
|
||||
expect(envExample).toContain('your_vk_service_token_here');
|
||||
expect(envExample).not.toMatch(/vk1\.[a-zA-Z0-9]/);
|
||||
});
|
||||
});
|
||||
138
tests/vk-errors.test.ts
Normal file
138
tests/vk-errors.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||
|
||||
describe('VK API error handling', () => {
|
||||
const token = 'vk1.a.test-service-token';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetchJson(json: unknown, status = 200) {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 500 ? 'Internal Server Error' : 'OK',
|
||||
json: async () => json,
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetchNetworkError(message = 'Network request failed') {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error(message));
|
||||
}
|
||||
|
||||
it('throws on invalid token (error_code 5)', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/User authorization failed/);
|
||||
});
|
||||
|
||||
it('throws on expired token (error_code 5)', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/User authorization failed/);
|
||||
});
|
||||
|
||||
it('throws on access denied (error_code 15)', async () => {
|
||||
mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Access denied/);
|
||||
});
|
||||
|
||||
it('throws on rate limit per second (error_code 6)', async () => {
|
||||
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/Too many requests/);
|
||||
});
|
||||
|
||||
it('throws on daily rate limit (error_code 29)', async () => {
|
||||
mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(/Rate limit reached/);
|
||||
});
|
||||
|
||||
it('throws on private profile (error_code 30)', async () => {
|
||||
mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/This profile is private/);
|
||||
});
|
||||
|
||||
it('throws on deleted or banned user (error_code 18)', async () => {
|
||||
mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/User was deleted or banned/);
|
||||
});
|
||||
|
||||
it('throws on deleted post (error_code 100 with post not found semantics)', async () => {
|
||||
mockFetchJson({ error: { error_code: 100, error_msg: 'One of the parameters specified was missing or invalid' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(/parameters specified was missing or invalid/);
|
||||
});
|
||||
|
||||
it('throws when post is not found in response', async () => {
|
||||
mockFetchJson({ response: { items: [] } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Post not found/);
|
||||
});
|
||||
|
||||
it('throws on unavailable community (error_code 203)', async () => {
|
||||
mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Access to the community is denied/);
|
||||
});
|
||||
|
||||
it('throws on empty response body', async () => {
|
||||
mockFetchJson({});
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Empty response/);
|
||||
});
|
||||
|
||||
it('throws on HTTP 500 from VK', async () => {
|
||||
mockFetchJson({}, 500);
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/HTTP error: 500/);
|
||||
});
|
||||
|
||||
it('throws on network timeout / failure', async () => {
|
||||
mockFetchNetworkError('fetch failed');
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/fetch failed/);
|
||||
});
|
||||
|
||||
it('does not leak the service token in thrown error messages', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
|
||||
try {
|
||||
await provider.fetchPost('https://vk.com/wall-1_1');
|
||||
} catch (err: any) {
|
||||
expect(err.message).not.toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retry transient errors by default', async () => {
|
||||
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
|
||||
expect(global.fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
190
tests/vk-mock-provider.test.ts
Normal file
190
tests/vk-mock-provider.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
|
||||
import { RawParticipant } from '../src/core/types/participant';
|
||||
|
||||
describe('VkMockProvider scenarios', () => {
|
||||
let provider: VkMockProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new VkMockProvider();
|
||||
});
|
||||
|
||||
it('default scenario returns 35 participants', async () => {
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants).toHaveLength(35);
|
||||
expect(participants[0]).toHaveProperty('platformUserId');
|
||||
expect(participants[0]).toHaveProperty('liked');
|
||||
});
|
||||
|
||||
it('supports 0 participants', async () => {
|
||||
provider.setScenario({ participantCount: 0 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('supports 1 participant', async () => {
|
||||
provider.setScenario({ participantCount: 1 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants).toHaveLength(1);
|
||||
expect(participants[0].platformUserId).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports 10 participants', async () => {
|
||||
provider.setScenario({ participantCount: 10 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('supports 1000 participants', async () => {
|
||||
provider.setScenario({ participantCount: 1000 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants).toHaveLength(1000);
|
||||
// IDs must be unique before deduplication
|
||||
const ids = new Set(participants.map(p => p.platformUserId));
|
||||
expect(ids.size).toBe(1000);
|
||||
});
|
||||
|
||||
it('supports large participant counts efficiently', async () => {
|
||||
provider.setScenario({ participantCount: 50000 });
|
||||
const start = Date.now();
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
const duration = Date.now() - start;
|
||||
expect(participants).toHaveLength(50000);
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('supports likes-only scenario', async () => {
|
||||
provider.setScenario({ participantCount: 20, likedRatio: 1, commentedRatio: 0 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants.every(p => p.liked)).toBe(true);
|
||||
expect(participants.every(p => !p.commented)).toBe(true);
|
||||
});
|
||||
|
||||
it('supports comments-only scenario', async () => {
|
||||
provider.setScenario({ participantCount: 20, likedRatio: 0, commentedRatio: 1 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants.every(p => !p.liked)).toBe(true);
|
||||
expect(participants.every(p => p.commented)).toBe(true);
|
||||
});
|
||||
|
||||
it('supports mixed likes + comments scenario', async () => {
|
||||
provider.setScenario({ participantCount: 100, likedRatio: 0.7, commentedRatio: 0.4 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
const likedCount = participants.filter(p => p.liked).length;
|
||||
const commentedCount = participants.filter(p => p.commented).length;
|
||||
expect(likedCount).toBeGreaterThan(50);
|
||||
expect(likedCount).toBeLessThan(90);
|
||||
expect(commentedCount).toBeGreaterThan(20);
|
||||
expect(commentedCount).toBeLessThan(60);
|
||||
});
|
||||
|
||||
it('checkSubscription returns subscribed / not subscribed deterministically', async () => {
|
||||
provider.setScenario({ participantCount: 10, subscribedRatio: 0.5 });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
const ids = participants.map(p => p.platformUserId);
|
||||
const map = await provider.checkSubscription(ids, '-100');
|
||||
let subscribedCount = 0;
|
||||
ids.forEach(id => {
|
||||
if (map.get(id)) subscribedCount++;
|
||||
});
|
||||
expect(subscribedCount).toBeGreaterThan(0);
|
||||
expect(subscribedCount).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('legacy checkSubscription marks users ending in 0 or 5 as not subscribed', async () => {
|
||||
provider.resetScenario();
|
||||
// Generated IDs are 1000000 + i*137. i=5 -> 1000685 (ends 5), i=10 -> 1001370 (ends 0).
|
||||
const map = await provider.checkSubscription(
|
||||
['1000137', '1000685', '1001370', '1000411'],
|
||||
'-100'
|
||||
);
|
||||
expect(map.get('1000685')).toBe(false); // ends with 5
|
||||
expect(map.get('1001370')).toBe(false); // ends with 0
|
||||
expect(map.get('1000137')).toBe(true); // ends with 7
|
||||
expect(map.get('1000411')).toBe(true); // ends with 1
|
||||
});
|
||||
|
||||
it('allows injecting duplicates via extraParticipants', async () => {
|
||||
provider.setScenario({
|
||||
participantCount: 2,
|
||||
extraParticipants: [
|
||||
{
|
||||
platformUserId: '1000137',
|
||||
firstName: 'Duplicate',
|
||||
lastName: 'Entry',
|
||||
source: 'COMMENTS',
|
||||
liked: false,
|
||||
commented: true,
|
||||
commentsCount: 1,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
expect(participants).toHaveLength(3);
|
||||
const dupes = participants.filter(p => p.platformUserId === '1000137');
|
||||
expect(dupes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks configured adminIds as admins', async () => {
|
||||
provider.setScenario({ participantCount: 5, adminIds: ['1000137', '1000411'] });
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
});
|
||||
const admin1 = participants.find(p => p.platformUserId === '1000137');
|
||||
const admin2 = participants.find(p => p.platformUserId === '1000411');
|
||||
const nonAdmin = participants.find(p => p.platformUserId === '1000274');
|
||||
expect(admin1?.isAdmin).toBe(true);
|
||||
expect(admin2?.isAdmin).toBe(true);
|
||||
expect(nonAdmin?.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchPost returns metadata with parsed ownerId and postId', async () => {
|
||||
const post = await provider.fetchPost('https://vk.com/wall-123456_789');
|
||||
expect(post.platform).toBe('VK');
|
||||
expect(post.ownerId).toBe('-123456');
|
||||
expect(post.postId).toBe('789');
|
||||
expect(post.likesCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fetchPost falls back to default ids for unparsable url', async () => {
|
||||
const post = await provider.fetchPost('not-a-url');
|
||||
expect(post.ownerId).toBe('-22446688');
|
||||
expect(post.postId).toBe('1054');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue