From 50973b4f859729041ab6f1f68b41f3d50f282c9d Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 01:53:56 +0700 Subject: [PATCH] feat(core): Phase 1.4 Production Hardening - double draw atomic protection, snapshot concurrency safety, Zod API validation, payload scalability & pagination, provider safety, normalized errors, idempotency, rate limiting, and 20 concurrent draw integration tests --- package-lock.json | 12 +- package.json | 3 +- src/app/api/giveaways/[id]/draw/route.ts | 58 ++- .../api/giveaways/[id]/participants/route.ts | 112 ++++-- src/app/api/giveaways/[id]/route.ts | 9 +- src/app/api/giveaways/[id]/snapshot/route.ts | 61 ++- src/app/api/giveaways/[id]/verify/route.ts | 23 +- src/app/api/giveaways/route.ts | 61 ++- src/app/api/posts/preview/route.ts | 36 +- src/app/page.tsx | 8 +- src/core/errors/http-errors.ts | 143 +++++++ src/core/validation/giveaway-schemas.ts | 92 +++++ src/lib/giveaway-store.ts | 28 +- src/lib/idempotency.ts | 34 ++ src/lib/rate-limiter.ts | 70 ++++ src/lib/repository/giveaway-repository.ts | 76 +++- src/lib/repository/memory-repository.ts | 109 +++++- src/lib/repository/prisma-repository.ts | 330 +++++++++++----- src/providers/factory.ts | 29 ++ tests/api-validation.test.ts | 359 ++++-------------- tests/concurrency-draw.test.ts | 84 ++++ tests/concurrency-snapshot.test.ts | 88 +++++ tests/idempotency.test.ts | 18 + tests/payload-scalability.test.ts | 91 +++++ tests/provider-safety.test.ts | 38 ++ tests/rate-limiter.test.ts | 24 ++ 26 files changed, 1445 insertions(+), 551 deletions(-) create mode 100644 src/core/errors/http-errors.ts create mode 100644 src/core/validation/giveaway-schemas.ts create mode 100644 src/lib/idempotency.ts create mode 100644 src/lib/rate-limiter.ts create mode 100644 src/providers/factory.ts create mode 100644 tests/concurrency-draw.test.ts create mode 100644 tests/concurrency-snapshot.test.ts create mode 100644 tests/idempotency.test.ts create mode 100644 tests/payload-scalability.test.ts create mode 100644 tests/provider-safety.test.ts create mode 100644 tests/rate-limiter.test.ts diff --git a/package-lock.json b/package-lock.json index 7b6df1e..760fd8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "next": "^14.2.15", "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.16.11", @@ -7803,6 +7804,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a00ba9d..dc97ee0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "next": "^14.2.15", "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.16.11", diff --git a/src/app/api/giveaways/[id]/draw/route.ts b/src/app/api/giveaways/[id]/draw/route.ts index db4f407..94c6b53 100644 --- a/src/app/api/giveaways/[id]/draw/route.ts +++ b/src/app/api/giveaways/[id]/draw/route.ts @@ -1,8 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; -import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic'; -import { generateCryptoSecureSeed } from '@/core/randomizer/hasher'; import { GiveawayFSM } from '@/core/fsm/giveaway-fsm'; +import { generateCryptoSecureSeed } from '@/core/randomizer/hasher'; +import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic'; +import { executeDrawSchema } from '@/core/validation/giveaway-schemas'; +import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors'; +import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; export async function POST( req: NextRequest, @@ -10,42 +13,35 @@ export async function POST( ) { try { const { id } = params; - const body = await req.json().catch(() => ({})); + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + expensiveApiRateLimiter.assertAllowed(`draw-execute:${ip}:${id}`); + const giveaway = await GiveawayStore.getById(id); - if (!giveaway) { - return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + throw new NotFoundError(`Giveaway with id "${id}" not found`); } - // 1. Guard check with FSM - if (giveaway.status === 'DRAWN') { - return NextResponse.json({ - error: 'Розыгрыш уже проведен. Повторный запуск строго запрещен.', - }, { status: 400 }); - } + // 1. Strict FSM Guard: Draw is permitted ONLY in SNAPSHOT_LOCKED status + GiveawayFSM.assertCanDraw(giveaway.status); - // 2. Fetch locked snapshot (or lock current eligible if ready) - let snapshot = await GiveawayStore.getLatestSnapshot(id); + // 2. Strict Snapshot requirement: Never create a snapshot implicitly + const snapshot = giveaway.latestSnapshot; if (!snapshot) { - const eligible = giveaway.participants.filter(p => p.eligible); - if (eligible.length === 0) { - return NextResponse.json({ - error: 'Нет допущенных участников для создания слепка и розыгрыша' - }, { status: 400 }); - } - snapshot = await GiveawayStore.createAndLockSnapshot(id, eligible, giveaway.filterRules); + throw new ConflictError( + 'Cannot execute draw: no locked participant snapshot exists. Lock a snapshot before drawing.' + ); } - // Validate status after snapshot lock - GiveawayFSM.assertCanDraw('SNAPSHOT_LOCKED'); + const rawBody = await req.json().catch(() => ({})); + const validated = executeDrawSchema.parse(rawBody); - const winnersCount = body.winnersCount || giveaway.winnersCount || 1; - const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0; - - // Seed must be generated with CSPRNG if not provided - const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed(); + const winnersCount = validated.winnersCount; + const reserveWinnersCount = validated.reserveWinnersCount; - // 3. Execute Provably Fair Randomizer V1 + // Use CSPRNG crypto.randomBytes seed if none provided (Math.random is strictly forbidden) + const seed = (validated.seed && validated.seed.trim()) || generateCryptoSecureSeed(); + + // 3. Execute Provably Fair Fisher-Yates Draw V1 const drawResult = executeDeterministicDrawV1({ giveawayId: id, snapshot, @@ -56,15 +52,15 @@ export async function POST( filterRules: giveaway.filterRules, }); - // 4. Persist DrawResult and AuditRecord atomically + // 4. Save DrawResult & AuditRecord in database with atomic status transition const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult); return NextResponse.json({ success: true, - drawResult, giveaway: updatedGiveaway, + drawResult, }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/giveaways/[id]/participants/route.ts b/src/app/api/giveaways/[id]/participants/route.ts index 5101acc..1471109 100644 --- a/src/app/api/giveaways/[id]/participants/route.ts +++ b/src/app/api/giveaways/[id]/participants/route.ts @@ -1,8 +1,37 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; -import { ProviderRegistry } from '@/providers/registry'; +import { ProviderFactory } from '@/providers/factory'; import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher'; -import { validateFilterRulesAgainstProviderCapabilities } from '@/core/filtering/rule-validation'; +import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas'; +import { handleApiError, NotFoundError } from '@/core/errors/http-errors'; +import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter'; +import { IdempotencyStore } from '@/lib/idempotency'; + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + generalApiRateLimiter.assertAllowed(`participants-get:${ip}`); + + const { searchParams } = new URL(req.url); + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); + const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '50', 10))); + const tabParam = searchParams.get('tab') || 'all'; + const tab = (tabParam === 'eligible' || tabParam === 'excluded') ? tabParam : 'all'; + + const result = await GiveawayStore.getParticipantsPaginated(id, page, pageSize, tab); + + return NextResponse.json({ + success: true, + ...result, + }); + } catch (error: any) { + return handleApiError(error); + } +} export async function POST( req: NextRequest, @@ -10,54 +39,65 @@ export async function POST( ) { try { const { id } = params; - const body = await req.json().catch(() => ({})); + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + expensiveApiRateLimiter.assertAllowed(`participants-import:${ip}:${id}`); + + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + const cached = IdempotencyStore.get(`import-part:${idempotencyKey}`); + if (cached) { + return NextResponse.json(cached.body, { status: cached.statusCode }); + } + } + const giveaway = await GiveawayStore.getById(id); - if (!giveaway) { - return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + throw new NotFoundError(`Giveaway with id "${id}" not found`); } - const rules = body.filterRules || giveaway.filterRules; - const provider = ProviderRegistry.getProvider(giveaway.platform); + const rawBody = await req.json(); + const validated = fetchParticipantsSchema.parse(rawBody); - // 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 } - ); - } + const provider = ProviderFactory.getVkProvider(); + validateProviderCapabilities(validated.filterRules, provider.capabilities); - // 1. Fetch raw participants + // Fetch raw participants from social provider const rawParticipants = await provider.fetchParticipants({ ownerId: giveaway.platformOwnerId, postId: giveaway.platformPostId, - sourceUrl: giveaway.sourceUrl, - includeLikes: true, - includeComments: rules.requireComment, - includeReposts: rules.requireRepost, + includeLikes: validated.filterRules.requireLike, + includeComments: validated.filterRules.requireComment, }); - // 2. Run enrichment pipeline (subscription check + filter engine) - const filterResult = await executeParticipantPipeline({ - rawParticipants, - rules, - provider, - ownerId: giveaway.platformOwnerId, - }); + // Run participant fetch, enrichment, and filtering pipeline + const { allParticipants, eligibleParticipants, excludedParticipants } = + await executeParticipantPipeline({ + rawParticipants, + rules: validated.filterRules, + provider, + ownerId: giveaway.platformOwnerId, + }); - // 3. Save participants into persistent database - await GiveawayStore.updateParticipants(id, filterResult.allParticipants); + // Save atomic participant state in store + const updated = await GiveawayStore.updateParticipants(id, allParticipants); - return NextResponse.json({ + const responseBody = { success: true, - stats: filterResult.stats, - allParticipants: filterResult.allParticipants, - eligibleCount: filterResult.eligibleParticipants.length, - excludedCount: filterResult.excludedParticipants.length, - }); + giveawayId: updated.id, + totalCount: allParticipants.length, + eligibleCount: eligibleParticipants.length, + excludedCount: excludedParticipants.length, + allParticipants, + eligibleParticipants, + excludedParticipants, + }; + + if (idempotencyKey) { + IdempotencyStore.set(`import-part:${idempotencyKey}`, 200, responseBody); + } + + return NextResponse.json(responseBody); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/giveaways/[id]/route.ts b/src/app/api/giveaways/[id]/route.ts index 5efe235..b8214be 100644 --- a/src/app/api/giveaways/[id]/route.ts +++ b/src/app/api/giveaways/[id]/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; +import { handleApiError, NotFoundError } from '@/core/errors/http-errors'; +import { generalApiRateLimiter } from '@/lib/rate-limiter'; export async function GET( req: NextRequest, @@ -7,14 +9,17 @@ export async function GET( ) { try { const { id } = params; + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + generalApiRateLimiter.assertAllowed(`giveaway-get:${ip}`); + const giveaway = await GiveawayStore.getById(id); if (!giveaway) { - return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + throw new NotFoundError(`Giveaway with id "${id}" not found`); } return NextResponse.json({ success: true, giveaway }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/giveaways/[id]/snapshot/route.ts b/src/app/api/giveaways/[id]/snapshot/route.ts index a444674..eec0b5b 100644 --- a/src/app/api/giveaways/[id]/snapshot/route.ts +++ b/src/app/api/giveaways/[id]/snapshot/route.ts @@ -1,5 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; +import { ProviderFactory } from '@/providers/factory'; +import { applyFilterRules } from '@/core/filtering/filter-engine'; +import { createSnapshotSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas'; +import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors'; +import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; +import { IdempotencyStore } from '@/lib/idempotency'; export async function POST( req: NextRequest, @@ -7,33 +13,62 @@ export async function POST( ) { try { const { id } = params; - const body = await req.json().catch(() => ({})); + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${ip}:${id}`); + + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + const cached = IdempotencyStore.get(`lock-snap:${idempotencyKey}`); + if (cached) { + return NextResponse.json(cached.body, { status: cached.statusCode }); + } + } + const giveaway = await GiveawayStore.getById(id); - if (!giveaway) { - return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + throw new NotFoundError(`Giveaway with id "${id}" not found`); } - const eligibleParticipants = giveaway.participants.filter(p => p.eligible); + if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') { + throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`); + } + + const rawBody = await req.json(); + const validated = createSnapshotSchema.parse(rawBody); + + const provider = ProviderFactory.getVkProvider(); + validateProviderCapabilities(validated.filterRules, provider.capabilities); + + // Apply strict filtering on current participants + const { eligibleParticipants } = applyFilterRules( + giveaway.participants, + validated.filterRules + ); + if (eligibleParticipants.length === 0) { - return NextResponse.json({ - error: 'Нельзя создать слепок с 0 допущенными участниками' - }, { status: 400 }); + throw new ConflictError('Cannot create snapshot with 0 eligible participants. Check your filter rules.'); } - const rules = body.filterRules || giveaway.filterRules; - + // Atomically create and lock snapshot in database const snapshot = await GiveawayStore.createAndLockSnapshot( id, eligibleParticipants, - rules + validated.filterRules ); - return NextResponse.json({ + const responseBody = { success: true, + giveawayId: id, + status: 'SNAPSHOT_LOCKED', snapshot, - }); + }; + + if (idempotencyKey) { + IdempotencyStore.set(`lock-snap:${idempotencyKey}`, 200, responseBody); + } + + return NextResponse.json(responseBody); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/giveaways/[id]/verify/route.ts b/src/app/api/giveaways/[id]/verify/route.ts index 14494a5..3b14ee6 100644 --- a/src/app/api/giveaways/[id]/verify/route.ts +++ b/src/app/api/giveaways/[id]/verify/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; import { verifyDrawResult } from '@/core/randomizer/deterministic'; +import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors'; +import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; export async function GET( req: NextRequest, @@ -8,25 +10,29 @@ export async function GET( ) { try { const { id } = params; - const giveaway = await GiveawayStore.getById(id); + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + expensiveApiRateLimiter.assertAllowed(`verify-get:${ip}:${id}`); + const giveaway = await GiveawayStore.getById(id); if (!giveaway) { - return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + throw new NotFoundError(`Giveaway with id "${id}" not found`); } const drawResult = giveaway.drawResult; if (!drawResult) { - return NextResponse.json({ - error: 'Giveaway has not been drawn yet. Nothing to verify.' - }, { status: 400 }); + throw new ConflictError('Giveaway has not been drawn yet. Nothing to verify.'); } // Strict snapshot lookup: DO NOT fallback to latestSnapshot const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId); if (!snapshot) { - return NextResponse.json({ - error: `Integrity Error: Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`, + return NextResponse.json({ + success: false, + error: { + code: 'INTEGRITY_ERROR', + message: `Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`, + }, verified: false, snapshotFound: false, }, { status: 404 }); @@ -52,6 +58,7 @@ export async function GET( }); return NextResponse.json({ + success: true, verified: verification.verified, giveawayId: id, drawId: drawResult.drawId, @@ -71,6 +78,6 @@ export async function GET( drawnAt: drawResult.drawnAt, }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/giveaways/route.ts b/src/app/api/giveaways/route.ts index d1ea3b9..76719a7 100644 --- a/src/app/api/giveaways/route.ts +++ b/src/app/api/giveaways/route.ts @@ -1,36 +1,63 @@ import { NextRequest, NextResponse } from 'next/server'; import { GiveawayStore } from '@/lib/giveaway-store'; -import { DEFAULT_FILTER_RULES } from '@/core/types/giveaway'; +import { createGiveawaySchema } from '@/core/validation/giveaway-schemas'; +import { handleApiError } from '@/core/errors/http-errors'; +import { generalApiRateLimiter } from '@/lib/rate-limiter'; +import { IdempotencyStore } from '@/lib/idempotency'; -export async function GET() { +export async function GET(req: NextRequest) { try { - const list = await GiveawayStore.listAll(); - return NextResponse.json({ success: true, giveaways: list }); + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + generalApiRateLimiter.assertAllowed(`giveaways-list:${ip}`); + + // Return lightweight summary for scalability (no massive participant/snapshot payloads) + const summaries = await GiveawayStore.listSummaries(); + return NextResponse.json({ + success: true, + giveaways: summaries, + totalCount: summaries.length, + }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } export async function POST(req: NextRequest) { try { - const body = await req.json(); - const { sourceUrl, post, filterRules = DEFAULT_FILTER_RULES, winnersCount = 1, reserveWinnersCount = 0, seed } = body; + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + generalApiRateLimiter.assertAllowed(`giveaway-create:${ip}`); - if (!sourceUrl || !post) { - return NextResponse.json({ error: 'sourceUrl and post are required' }, { status: 400 }); + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + const cached = IdempotencyStore.get(`create-gw:${idempotencyKey}`); + if (cached) { + return NextResponse.json(cached.body, { status: cached.statusCode }); + } } + const rawBody = await req.json(); + const validated = createGiveawaySchema.parse(rawBody); + const giveaway = await GiveawayStore.create({ - sourceUrl, - post, - filterRules, - winnersCount, - reserveWinnersCount, - seed, + sourceUrl: validated.sourceUrl, + post: validated.post, + filterRules: validated.filterRules, + winnersCount: validated.winnersCount, + reserveWinnersCount: validated.reserveWinnersCount, + seed: validated.seed, }); - return NextResponse.json({ success: true, giveaway }); + const responseBody = { + success: true, + giveaway, + }; + + if (idempotencyKey) { + IdempotencyStore.set(`create-gw:${idempotencyKey}`, 201, responseBody); + } + + return NextResponse.json(responseBody, { status: 201 }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/posts/preview/route.ts b/src/app/api/posts/preview/route.ts index 267bd0f..2d39d3d 100644 --- a/src/app/api/posts/preview/route.ts +++ b/src/app/api/posts/preview/route.ts @@ -1,31 +1,25 @@ import { NextRequest, NextResponse } from 'next/server'; -import { ProviderRegistry } from '@/providers/registry'; -import { PlatformType } from '@/core/types/giveaway'; +import { ProviderFactory } from '@/providers/factory'; +import { postPreviewSchema } from '@/core/validation/giveaway-schemas'; +import { handleApiError } from '@/core/errors/http-errors'; +import { generalApiRateLimiter } from '@/lib/rate-limiter'; export async function POST(req: NextRequest) { try { - const body = await req.json(); - const { url, platform = 'VK' } = body; + const ip = req.headers.get('x-forwarded-for') || 'anonymous'; + generalApiRateLimiter.assertAllowed(`post-preview:${ip}`); - if (!url || typeof url !== 'string') { - return NextResponse.json({ error: 'URL is required' }, { status: 400 }); - } + const rawBody = await req.json(); + const validated = postPreviewSchema.parse(rawBody); - const provider = ProviderRegistry.getProvider(platform as PlatformType); - const parsed = provider.parsePostUrl(url); + const provider = ProviderFactory.getVkProvider(); + const post = await provider.fetchPost(validated.url); - if (!parsed) { - return NextResponse.json({ - error: 'Неверный формат ссылки на запись VK. Пример: https://vk.com/wall-123456_789' - }, { status: 400 }); - } - - const postMetadata = await provider.fetchPost(url); - return NextResponse.json({ success: true, post: postMetadata }); + return NextResponse.json({ + success: true, + post, + }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Ошибка при загрузке данных поста' }, - { status: 500 } - ); + return handleApiError(error); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 63ba1ca..2b6ed9d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,10 +13,10 @@ import { ArrowRight, RefreshCw, } from 'lucide-react'; -import { StoredGiveaway } from '@/lib/giveaway-store'; +import { GiveawaySummary } from '@/lib/repository/giveaway-repository'; export default function DashboardPage() { - const [giveaways, setGiveaways] = useState([]); + const [giveaways, setGiveaways] = useState([]); const [loading, setLoading] = useState(true); const fetchGiveaways = async () => { @@ -39,7 +39,7 @@ export default function DashboardPage() { }, []); const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length; - const totalEligible = giveaways.reduce((acc, g) => acc + (g.drawResult?.totalEligibleCount || 0), 0); + const totalEligible = giveaways.reduce((acc, g) => acc + (g.eligibleParticipantsCount || 0), 0); return (
@@ -152,7 +152,7 @@ export default function DashboardPage() { {gw.title || 'Розыгрыш по записи VK'}

- {gw.description || gw.sourceUrl} + {gw.sourceUrl}

Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')} diff --git a/src/core/errors/http-errors.ts b/src/core/errors/http-errors.ts new file mode 100644 index 0000000..3691a55 --- /dev/null +++ b/src/core/errors/http-errors.ts @@ -0,0 +1,143 @@ +import { NextResponse } from 'next/server'; +import { ZodError } from 'zod'; + +export abstract class AppError extends Error { + abstract readonly statusCode: number; + abstract readonly code: string; + readonly details?: any; + + constructor(message: string, details?: any) { + super(message); + this.name = this.constructor.name; + this.details = details; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class ValidationError extends AppError { + readonly statusCode = 400; + readonly code = 'VALIDATION_ERROR'; +} + +export class UnauthorizedError extends AppError { + readonly statusCode = 401; + readonly code = 'UNAUTHORIZED'; +} + +export class ForbiddenError extends AppError { + readonly statusCode = 403; + readonly code = 'FORBIDDEN'; +} + +export class NotFoundError extends AppError { + readonly statusCode = 404; + readonly code = 'NOT_FOUND'; +} + +export class ConflictError extends AppError { + readonly statusCode = 409; + readonly code = 'CONFLICT'; +} + +export class RateLimitError extends AppError { + readonly statusCode = 429; + readonly code = 'RATE_LIMIT_EXCEEDED'; +} + +export class DependencyUnavailableError extends AppError { + readonly statusCode = 503; + readonly code = 'DEPENDENCY_UNAVAILABLE'; +} + +export class InternalError extends AppError { + readonly statusCode = 500; + readonly code = 'INTERNAL_SERVER_ERROR'; +} + +/** + * Normalizes any error into a consistent JSON response + */ +export function handleApiError(error: unknown): NextResponse { + // Handle Zod validation errors + if (error instanceof ZodError) { + const formattedIssues = error.issues.map(i => ({ + path: i.path.join('.'), + message: i.message, + })); + + return NextResponse.json( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request payload', + details: formattedIssues, + }, + }, + { status: 400 } + ); + } + + // Handle known AppErrors + if (error instanceof AppError) { + return NextResponse.json( + { + success: false, + error: { + code: error.code, + message: error.message, + details: error.details || null, + }, + }, + { status: error.statusCode } + ); + } + + // Handle SyntaxError (Malformed JSON in request body) + if (error instanceof SyntaxError && 'body' in error) { + return NextResponse.json( + { + success: false, + error: { + code: 'INVALID_JSON', + message: 'Malformed JSON payload in request body', + }, + }, + { status: 400 } + ); + } + + // Handle Prisma Known Request Errors + const anyErr = error as any; + if (anyErr?.code === 'P2002') { + return NextResponse.json( + { + success: false, + error: { + code: 'CONFLICT', + message: 'Resource conflict or duplicate constraint violation', + }, + }, + { status: 409 } + ); + } + + // Unexpected runtime errors + console.error('Unhandled server error:', error); + + const isProd = process.env.NODE_ENV === 'production'; + const errorMessage = isProd + ? 'An unexpected internal server error occurred' + : (error instanceof Error ? error.message : 'Internal Server Error'); + + return NextResponse.json( + { + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: errorMessage, + }, + }, + { status: 500 } + ); +} diff --git a/src/core/validation/giveaway-schemas.ts b/src/core/validation/giveaway-schemas.ts new file mode 100644 index 0000000..055057a --- /dev/null +++ b/src/core/validation/giveaway-schemas.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import { FilterRules } from '../types/giveaway'; +import { ProviderCapabilities } from '../../providers/types'; +import { ValidationError } from '../errors/http-errors'; + +export const filterRulesSchema = z.object({ + requireLike: z.boolean().default(false), + requireComment: z.boolean().default(false), + requireRepost: z.boolean().default(false), + requireSubscription: z.boolean().default(false), + excludeAdmins: z.boolean().default(false), + excludeDuplicateComments: z.boolean().default(true), + excludeBlacklistedIds: z.array(z.string().max(128)).max(1000).default([]), + targetGroupId: z.string().max(128).optional(), + minEligibleParticipants: z.number().int().min(1).max(100000).default(1), +}).strict(); + +const defaultRulesObject = { + requireLike: true, + requireComment: false, + requireRepost: false, + requireSubscription: false, + excludeAdmins: false, + excludeDuplicateComments: true, + excludeBlacklistedIds: [] as string[], + minEligibleParticipants: 1, +}; + +export const postMetadataSchema = z.object({ + platform: z.enum(['VK', 'TELEGRAM', 'YOUTUBE']), + ownerId: z.string().min(1).max(128), + postId: z.string().min(1).max(128), + sourceUrl: z.string().url().max(2048), + title: z.string().max(512), + text: z.string().max(10000).default(''), + imageUrl: z.string().url().max(2048).nullish().transform(v => v ?? undefined), + authorName: z.string().max(256).optional(), + authorAvatarUrl: z.string().url().max(2048).nullish().transform(v => v ?? undefined), + likesCount: z.number().int().min(0).default(0), + commentsCount: z.number().int().min(0).default(0), + repostsCount: z.number().int().min(0).default(0), +}); + +export const createGiveawaySchema = z.object({ + sourceUrl: z.string().min(1).max(2048), + post: postMetadataSchema, + filterRules: filterRulesSchema.default(defaultRulesObject), + winnersCount: z.number().int().min(1).max(100).default(1), + reserveWinnersCount: z.number().int().min(0).max(100).default(0), + seed: z.string().max(512).optional(), +}).strict(); + +export const fetchParticipantsSchema = z.object({ + filterRules: filterRulesSchema.default(defaultRulesObject), +}).strict(); + +export const createSnapshotSchema = z.object({ + filterRules: filterRulesSchema.default(defaultRulesObject), +}).strict(); + +export const executeDrawSchema = z.object({ + winnersCount: z.number().int().min(1).max(100).default(1), + reserveWinnersCount: z.number().int().min(0).max(100).default(0), + seed: z.string().max(512).optional(), +}).strict(); + +export const postPreviewSchema = z.object({ + url: z.string().min(1).max(2048), + platform: z.enum(['VK', 'TELEGRAM', 'YOUTUBE']).default('VK'), +}).strict(); + +/** + * Validates requested filter rules against provider capabilities + */ +export function validateProviderCapabilities( + rules: FilterRules, + capabilities: ProviderCapabilities +): void { + if (rules.requireRepost && !capabilities.reposts) { + throw new ValidationError( + 'Repost verification is not supported due to VK API privacy limitations', + { condition: 'requireRepost' } + ); + } + + if (rules.excludeAdmins && !capabilities.adminDetection) { + throw new ValidationError( + 'Admin detection requires VK ID organizer authorization', + { condition: 'excludeAdmins' } + ); + } +} diff --git a/src/lib/giveaway-store.ts b/src/lib/giveaway-store.ts index 16523c5..0afd587 100644 --- a/src/lib/giveaway-store.ts +++ b/src/lib/giveaway-store.ts @@ -1,4 +1,10 @@ -import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository'; +import { + IGiveawayRepository, + GiveawayWithRelations, + GiveawaySummary, + PaginatedParticipantsResult, + CreateGiveawayInput +} from './repository/giveaway-repository'; import { PrismaGiveawayRepository } from './repository/prisma-repository'; import { MemoryGiveawayRepository } from './repository/memory-repository'; import { FilterRules } from '../core/types/giveaway'; @@ -7,7 +13,6 @@ import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audi export type StoredGiveaway = GiveawayWithRelations; -// Select initial repository based on explicit STORAGE_DRIVER configuration function createDefaultRepository(): IGiveawayRepository { if (process.env.STORAGE_DRIVER === 'memory') { return new MemoryGiveawayRepository(); @@ -18,9 +23,6 @@ function createDefaultRepository(): IGiveawayRepository { let activeRepository: IGiveawayRepository = createDefaultRepository(); export class GiveawayStore { - /** - * Set custom repository (e.g. MemoryGiveawayRepository in tests) - */ static setRepository(repo: IGiveawayRepository): void { activeRepository = repo; } @@ -29,9 +31,6 @@ export class GiveawayStore { return activeRepository; } - /** - * Reset repository to environment default - */ static resetToDefault(): void { activeRepository = createDefaultRepository(); } @@ -48,6 +47,19 @@ export class GiveawayStore { return await activeRepository.listGiveaways(); } + static async listSummaries(): Promise { + return await activeRepository.listGiveawaysSummary(); + } + + static async getParticipantsPaginated( + id: string, + page: number = 1, + pageSize: number = 50, + tab: 'all' | 'eligible' | 'excluded' = 'all' + ): Promise { + return await activeRepository.getParticipantsPaginated(id, page, pageSize, tab); + } + static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise { return await activeRepository.saveParticipants(id, participants); } diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts new file mode 100644 index 0000000..4de9872 --- /dev/null +++ b/src/lib/idempotency.ts @@ -0,0 +1,34 @@ +interface IdempotentResponse { + statusCode: number; + body: any; + createdAt: number; +} + +export class IdempotencyStore { + private static store = new Map(); + private static readonly TTL_MS = 5 * 60 * 1000; // 5 minutes + + public static get(key: string): IdempotentResponse | null { + const cached = this.store.get(key); + if (!cached) return null; + + if (Date.now() - cached.createdAt > this.TTL_MS) { + this.store.delete(key); + return null; + } + + return cached; + } + + public static set(key: string, statusCode: number, body: any): void { + this.store.set(key, { + statusCode, + body, + createdAt: Date.now(), + }); + } + + public static clear(): void { + this.store.clear(); + } +} diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts new file mode 100644 index 0000000..2f62077 --- /dev/null +++ b/src/lib/rate-limiter.ts @@ -0,0 +1,70 @@ +import { RateLimitError } from '../core/errors/http-errors'; + +interface RateLimitRecord { + timestamps: number[]; +} + +export interface RateLimiterOptions { + windowMs: number; + maxRequests: number; +} + +export class SlidingWindowRateLimiter { + private records = new Map(); + private readonly windowMs: number; + private readonly maxRequests: number; + + constructor(options: RateLimiterOptions) { + this.windowMs = options.windowMs; + this.maxRequests = options.maxRequests; + } + + public check(key: string): { allowed: boolean; remaining: number; resetInMs: number } { + const now = Date.now(); + const windowStart = now - this.windowMs; + + let record = this.records.get(key); + if (!record) { + record = { timestamps: [] }; + this.records.set(key, record); + } + + // Purge timestamps outside current window + record.timestamps = record.timestamps.filter(ts => ts > windowStart); + + if (record.timestamps.length >= this.maxRequests) { + const oldest = record.timestamps[0]; + const resetInMs = Math.max(0, oldest + this.windowMs - now); + return { allowed: false, remaining: 0, resetInMs }; + } + + record.timestamps.push(now); + const remaining = this.maxRequests - record.timestamps.length; + return { allowed: true, remaining, resetInMs: this.windowMs }; + } + + public assertAllowed(key: string): void { + const result = this.check(key); + if (!result.allowed) { + throw new RateLimitError( + `Rate limit exceeded. Please retry after ${Math.ceil(result.resetInMs / 1000)} seconds.`, + { retryAfterMs: result.resetInMs } + ); + } + } + + public reset(): void { + this.records.clear(); + } +} + +// Global default limiter instances for expensive operations +export const expensiveApiRateLimiter = new SlidingWindowRateLimiter({ + windowMs: 10_000, // 10 seconds + maxRequests: 15, +}); + +export const generalApiRateLimiter = new SlidingWindowRateLimiter({ + windowMs: 60_000, // 1 minute + maxRequests: 120, +}); diff --git a/src/lib/repository/giveaway-repository.ts b/src/lib/repository/giveaway-repository.ts index 532ae56..b7954e0 100644 --- a/src/lib/repository/giveaway-repository.ts +++ b/src/lib/repository/giveaway-repository.ts @@ -1,16 +1,7 @@ import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../../core/types/giveaway'; -import { FilteredParticipant, RawParticipant } from '../../core/types/participant'; +import { FilteredParticipant } from '../../core/types/participant'; import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit'; -export interface CreateGiveawayInput { - sourceUrl: string; - post: PostMetadata; - filterRules: FilterRules; - winnersCount?: number; - reserveWinnersCount?: number; - seed?: string; -} - export interface GiveawayWithRelations { id: string; platform: PlatformType; @@ -33,17 +24,74 @@ export interface GiveawayWithRelations { drawnAt: string | null; participants: FilteredParticipant[]; snapshots: ParticipantSnapshotData[]; - latestSnapshot?: ParticipantSnapshotData | null; - drawResult?: DrawExecutionResult | null; + latestSnapshot: ParticipantSnapshotData | null; + drawResult: DrawExecutionResult | null; +} + +export interface GiveawaySummary { + id: string; + platform: PlatformType; + sourceUrl: string; + platformOwnerId: string; + platformPostId: string; + title: string; + postImageUrl: string | null; + postLikesCount: number; + postCommentsCount: number; + postRepostsCount: number; + status: GiveawayStatusType; + winnersCount: number; + reserveWinnersCount: number; + createdAt: string; + updatedAt: string; + drawnAt: string | null; + totalParticipantsCount: number; + eligibleParticipantsCount: number; + hasDrawResult: boolean; + algorithmVersion: string | null; +} + +export interface PaginatedParticipantsResult { + participants: FilteredParticipant[]; + page: number; + pageSize: number; + totalCount: number; + eligibleCount: number; + excludedCount: number; + totalPages: number; +} + +export interface CreateGiveawayInput { + sourceUrl: string; + post: PostMetadata; + filterRules: FilterRules; + winnersCount?: number; + reserveWinnersCount?: number; + seed?: string; } export interface IGiveawayRepository { createGiveaway(input: CreateGiveawayInput): Promise; getGiveawayById(id: string): Promise; listGiveaways(): Promise; + listGiveawaysSummary(): Promise; + getParticipantsPaginated( + id: string, + page: number, + pageSize: number, + tab?: 'all' | 'eligible' | 'excluded' + ): Promise; updateStatus(id: string, status: GiveawayStatusType): Promise; saveParticipants(id: string, participants: FilteredParticipant[]): Promise; - createAndLockSnapshot(id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules): Promise; + createAndLockSnapshot( + id: string, + eligibleParticipants: FilteredParticipant[], + rules: FilterRules + ): Promise; getLatestSnapshot(giveawayId: string): Promise; - saveDrawResultAndAudit(id: string, snapshotId: string, result: DrawExecutionResult): Promise; + saveDrawResultAndAudit( + id: string, + snapshotId: string, + result: DrawExecutionResult + ): Promise; } diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts index 0d0e549..a4c15cc 100644 --- a/src/lib/repository/memory-repository.ts +++ b/src/lib/repository/memory-repository.ts @@ -1,17 +1,21 @@ import { IGiveawayRepository, CreateGiveawayInput, - GiveawayWithRelations + GiveawayWithRelations, + GiveawaySummary, + PaginatedParticipantsResult } from './giveaway-repository'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilteredParticipant } from '../../core/types/participant'; import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit'; import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical'; import { GiveawayFSM } from '../../core/fsm/giveaway-fsm'; +import { ConflictError, NotFoundError } from '../../core/errors/http-errors'; export class MemoryGiveawayRepository implements IGiveawayRepository { private giveaways: Map = new Map(); private snapshots: Map = new Map(); + private drawLocks: Set = new Set(); async createGiveaway(input: CreateGiveawayInput): Promise { const id = 'gw_' + Math.random().toString(36).slice(2, 10); @@ -24,7 +28,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { platformOwnerId: input.post.ownerId, platformPostId: input.post.postId, title: input.post.title, - description: input.post.text, + description: input.post.text || null, postImageUrl: input.post.imageUrl || null, postLikesCount: input.post.likesCount, postCommentsCount: input.post.commentsCount, @@ -57,7 +61,6 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { let drawResult = gw.drawResult; if (drawResult) { - // Strictly bind to the snapshot referenced by snapshotId const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest; drawResult = { ...drawResult, @@ -79,26 +82,84 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { return all.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); } + async listGiveawaysSummary(): Promise { + const all = await this.listGiveaways(); + return all.map(gw => ({ + id: gw.id, + platform: gw.platform, + sourceUrl: gw.sourceUrl, + platformOwnerId: gw.platformOwnerId, + platformPostId: gw.platformPostId, + title: gw.title, + postImageUrl: gw.postImageUrl, + postLikesCount: gw.postLikesCount, + postCommentsCount: gw.postCommentsCount, + postRepostsCount: gw.postRepostsCount, + status: gw.status, + winnersCount: gw.winnersCount, + reserveWinnersCount: gw.reserveWinnersCount, + createdAt: gw.createdAt, + updatedAt: gw.updatedAt, + drawnAt: gw.drawnAt, + totalParticipantsCount: gw.participants.length, + eligibleParticipantsCount: gw.drawResult?.totalEligibleCount || gw.participants.filter(p => p.eligible).length, + hasDrawResult: Boolean(gw.drawResult), + algorithmVersion: gw.drawResult?.algorithmVersion || null, + })); + } + + async getParticipantsPaginated( + id: string, + page: number = 1, + pageSize: number = 50, + tab: 'all' | 'eligible' | 'excluded' = 'all' + ): Promise { + const gw = this.giveaways.get(id); + if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); + + const all = gw.participants; + const eligibleCount = all.filter(p => p.eligible).length; + const excludedCount = all.filter(p => !p.eligible).length; + const totalCount = all.length; + + let filtered = all; + if (tab === 'eligible') filtered = all.filter(p => p.eligible); + if (tab === 'excluded') filtered = all.filter(p => !p.eligible); + + const relevantCount = filtered.length; + const totalPages = Math.ceil(relevantCount / pageSize) || 1; + const start = (page - 1) * pageSize; + const paginatedItems = filtered.slice(start, start + pageSize); + + return { + participants: paginatedItems, + page, + pageSize, + totalCount, + eligibleCount, + excludedCount, + totalPages, + }; + } + async updateStatus(id: string, newStatus: GiveawayStatusType): Promise { - const gw = await this.getGiveawayById(id); - if (!gw) throw new Error(`Giveaway with id "${id}" not found`); + const gw = this.giveaways.get(id); + if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); GiveawayFSM.validateTransition(gw.status, newStatus); gw.status = newStatus; gw.updatedAt = new Date().toISOString(); - this.giveaways.set(id, gw); return gw; } async saveParticipants(id: string, participants: FilteredParticipant[]): Promise { - const gw = await this.getGiveawayById(id); - if (!gw) throw new Error(`Giveaway with id "${id}" not found`); + const gw = this.giveaways.get(id); + if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); GiveawayFSM.assertCanModifyParticipants(gw.status); gw.participants = participants; gw.status = 'READY'; gw.updatedAt = new Date().toISOString(); - this.giveaways.set(id, gw); return gw; } @@ -107,15 +168,15 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { eligibleParticipants: FilteredParticipant[], rules: FilterRules ): Promise { - const gw = await this.getGiveawayById(id); - if (!gw) throw new Error(`Giveaway with id "${id}" not found`); + const gw = this.giveaways.get(id); + if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') { - throw new Error(`Cannot lock snapshot in final status "${gw.status}"`); + throw new ConflictError(`Cannot lock snapshot in final status "${gw.status}"`); } if (eligibleParticipants.length === 0) { - throw new Error('Cannot create snapshot with 0 eligible participants'); + throw new ConflictError('Cannot create snapshot with 0 eligible participants'); } const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants); @@ -144,7 +205,6 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { gw.filterRules = rules; gw.latestSnapshot = snapshot; gw.updatedAt = new Date().toISOString(); - this.giveaways.set(id, gw); return snapshot; } @@ -159,17 +219,30 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { snapshotId: string, result: DrawExecutionResult ): Promise { - const gw = await this.getGiveawayById(id); - if (!gw) throw new Error(`Giveaway with id "${id}" not found`); + // Atomic test & set lock check + if (this.drawLocks.has(id)) { + throw new ConflictError(`Giveaway "${id}" has already been drawn or is concurrently drawing.`); + } - GiveawayFSM.assertCanDraw(gw.status); + const gw = this.giveaways.get(id); + if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`); + + if (gw.status !== 'SNAPSHOT_LOCKED') { + throw new ConflictError(`Giveaway is in status "${gw.status}", but draw requires "SNAPSHOT_LOCKED"`); + } + + if (gw.drawResult) { + throw new ConflictError(`Giveaway "${id}" has already been drawn.`); + } + + // Acquire lock and transition + this.drawLocks.add(id); gw.drawResult = result; gw.status = 'DRAWN'; gw.drawnAt = result.drawnAt; gw.seed = result.seedUsed; gw.updatedAt = new Date().toISOString(); - this.giveaways.set(id, gw); return gw; } diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index 37dbf66..81081df 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -2,13 +2,16 @@ import { prisma } from '../prisma'; import { IGiveawayRepository, CreateGiveawayInput, - GiveawayWithRelations + GiveawayWithRelations, + GiveawaySummary, + PaginatedParticipantsResult } from './giveaway-repository'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilteredParticipant } from '../../core/types/participant'; import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit'; import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical'; import { GiveawayFSM } from '../../core/fsm/giveaway-fsm'; +import { ConflictError, NotFoundError } from '../../core/errors/http-errors'; export class PrismaGiveawayRepository implements IGiveawayRepository { private mapPrismaGiveaway(raw: any): GiveawayWithRelations { @@ -46,7 +49,6 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { let drawResult: DrawExecutionResult | null = null; if (raw.drawResult) { - // Strictly bind to the snapshot attached to drawResult const boundSnapshot = raw.drawResult.snapshot ? { participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash, @@ -166,9 +168,119 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { return list.map(item => this.mapPrismaGiveaway(item)); } + async listGiveawaysSummary(): Promise { + const list = await prisma.giveaway.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + platform: true, + sourceUrl: true, + platformOwnerId: true, + platformPostId: true, + title: true, + postImageUrl: true, + postLikesCount: true, + postCommentsCount: true, + postRepostsCount: true, + status: true, + winnersCount: true, + reserveWinnersCount: true, + createdAt: true, + updatedAt: true, + drawnAt: true, + _count: { + select: { + participants: true, + }, + }, + drawResult: { + select: { + algorithmVersion: true, + totalEligibleCount: true, + }, + }, + }, + }); + + return list.map(item => ({ + id: item.id, + platform: item.platform as PlatformType, + sourceUrl: item.sourceUrl, + platformOwnerId: item.platformOwnerId, + platformPostId: item.platformPostId, + title: item.title, + postImageUrl: item.postImageUrl, + postLikesCount: item.postLikesCount, + postCommentsCount: item.postCommentsCount, + postRepostsCount: item.postRepostsCount, + status: item.status as GiveawayStatusType, + winnersCount: item.winnersCount, + reserveWinnersCount: item.reserveWinnersCount, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null, + totalParticipantsCount: item._count.participants, + eligibleParticipantsCount: item.drawResult?.totalEligibleCount || 0, + hasDrawResult: Boolean(item.drawResult), + algorithmVersion: item.drawResult?.algorithmVersion || null, + })); + } + + async getParticipantsPaginated( + id: string, + page: number = 1, + pageSize: number = 50, + tab: 'all' | 'eligible' | 'excluded' = 'all' + ): Promise { + const whereClause: any = { giveawayId: id }; + if (tab === 'eligible') whereClause.eligible = true; + if (tab === 'excluded') whereClause.eligible = false; + + const [totalCount, eligibleCount, excludedCount, items] = await prisma.$transaction([ + prisma.participant.count({ where: { giveawayId: id } }), + prisma.participant.count({ where: { giveawayId: id, eligible: true } }), + prisma.participant.count({ where: { giveawayId: id, eligible: false } }), + prisma.participant.findMany({ + where: whereClause, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { platformUserId: 'asc' }, + }), + ]); + + const participants: FilteredParticipant[] = items.map(p => ({ + platformUserId: p.platformUserId, + firstName: p.firstName, + lastName: p.lastName, + username: p.username || undefined, + avatarUrl: p.avatarUrl || undefined, + source: p.source, + liked: p.liked, + commented: p.commented, + commentsCount: p.commentsCount, + reposted: p.reposted, + subscribed: p.subscribed, + eligible: p.eligible, + exclusionReason: p.exclusionReason, + })); + + const relevantCount = tab === 'eligible' ? eligibleCount : tab === 'excluded' ? excludedCount : totalCount; + const totalPages = Math.ceil(relevantCount / pageSize) || 1; + + return { + participants, + page, + pageSize, + totalCount, + eligibleCount, + excludedCount, + totalPages, + }; + } + async updateStatus(id: string, newStatus: GiveawayStatusType): Promise { const current = await this.getGiveawayById(id); - if (!current) throw new Error(`Giveaway with id "${id}" not found`); + if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); GiveawayFSM.validateTransition(current.status, newStatus); @@ -189,7 +301,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { async saveParticipants(id: string, participants: FilteredParticipant[]): Promise { const current = await this.getGiveawayById(id); - if (!current) throw new Error(`Giveaway with id "${id}" not found`); + if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); GiveawayFSM.assertCanModifyParticipants(current.status); @@ -233,56 +345,75 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { rules: FilterRules ): Promise { const current = await this.getGiveawayById(id); - if (!current) throw new Error(`Giveaway with id "${id}" not found`); + if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); if (current.status === 'DRAWN' || current.status === 'PUBLISHED') { - throw new Error(`Cannot lock snapshot in final status "${current.status}"`); + throw new ConflictError(`Cannot lock snapshot in final status "${current.status}"`); } if (eligibleParticipants.length === 0) { - throw new Error('Cannot create snapshot with 0 eligible participants'); + throw new ConflictError('Cannot create snapshot with 0 eligible participants'); } const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants); const conditionsHash = computeConditionsHash(rules); - const latestVersion = current.snapshots.length > 0 - ? Math.max(...current.snapshots.map(s => s.version)) - : 0; - const newVersion = latestVersion + 1; + try { + return await prisma.$transaction(async (tx) => { + // Atomic status guard + const updateRes = await tx.giveaway.updateMany({ + where: { + id, + status: { in: ['READY', 'SNAPSHOT_LOCKED'] }, + }, + data: { + status: 'SNAPSHOT_LOCKED', + filterRules: rules as any, + }, + }); - const [snapshot] = await prisma.$transaction([ - prisma.participantSnapshot.create({ - data: { - giveawayId: id, - version: newVersion, - eligibleParticipants: eligibleParticipants as any, - filterRulesSnapshot: rules as any, - participantCount: eligibleParticipants.length, - participantsSnapshotHash, - conditionsHash, - }, - }), - prisma.giveaway.update({ - where: { id }, - data: { - status: 'SNAPSHOT_LOCKED', - filterRules: rules as any, - }, - }), - ]); + if (updateRes.count === 0) { + throw new ConflictError(`Concurrent modification or invalid status for giveaway "${id}"`); + } - return { - id: snapshot.id, - giveawayId: snapshot.giveawayId, - version: snapshot.version, - createdAt: snapshot.createdAt.toISOString(), - eligibleParticipants: eligibleParticipants, - filterRulesSnapshot: rules, - participantCount: snapshot.participantCount, - participantsSnapshotHash: snapshot.participantsSnapshotHash, - conditionsHash: snapshot.conditionsHash, - }; + const latestSnap = await tx.participantSnapshot.findFirst({ + where: { giveawayId: id }, + orderBy: { version: 'desc' }, + }); + + const newVersion = (latestSnap?.version || 0) + 1; + + const snapshot = await tx.participantSnapshot.create({ + data: { + giveawayId: id, + version: newVersion, + eligibleParticipants: eligibleParticipants as any, + filterRulesSnapshot: rules as any, + participantCount: eligibleParticipants.length, + participantsSnapshotHash, + conditionsHash, + }, + }); + + return { + id: snapshot.id, + giveawayId: snapshot.giveawayId, + version: snapshot.version, + createdAt: snapshot.createdAt.toISOString(), + eligibleParticipants, + filterRulesSnapshot: rules, + participantCount: snapshot.participantCount, + participantsSnapshotHash: snapshot.participantsSnapshotHash, + conditionsHash: snapshot.conditionsHash, + }; + }); + } catch (err: any) { + if (err instanceof ConflictError) throw err; + if (err?.code === 'P2002') { + throw new ConflictError('Concurrent snapshot creation conflict. Please retry.'); + } + throw err; + } } async getLatestSnapshot(giveawayId: string): Promise { @@ -312,61 +443,78 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { result: DrawExecutionResult ): Promise { const current = await this.getGiveawayById(id); - if (!current) throw new Error(`Giveaway with id "${id}" not found`); + if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); GiveawayFSM.assertCanDraw(current.status); - await prisma.$transaction(async (tx) => { - // 1. Create DrawResult with original drawId - await tx.drawResult.create({ - data: { - drawId: result.drawId, - giveawayId: id, - snapshotId: snapshotId, - winners: result.winners as any, - reserveWinners: result.reserveWinners as any, - winnerIds: result.winnerIds as any, - reserveWinnerIds: result.reserveWinnerIds as any, - totalEligibleCount: result.totalEligibleCount, - totalLoadedCount: result.totalLoadedCount, - seedUsed: result.seedUsed, - algorithmVersion: result.algorithmVersion, - deterministicProofHash: result.deterministicProofHash, - auditEventHash: result.auditEventHash, - drawnAt: new Date(result.drawnAt), - }, - }); + try { + await prisma.$transaction(async (tx) => { + // 1. Atomic conditional transition SNAPSHOT_LOCKED -> DRAWN + const updatedStatus = await tx.giveaway.updateMany({ + where: { + id, + status: 'SNAPSHOT_LOCKED', + }, + data: { + status: 'DRAWN', + drawnAt: new Date(result.drawnAt), + seed: result.seedUsed, + }, + }); - // 2. Create AuditRecord - await tx.auditRecord.create({ - data: { - giveawayId: id, - snapshotId: snapshotId, - algorithmVersion: result.algorithmVersion, - seed: result.seedUsed, - participantsSnapshotHash: result.participantsSnapshotHash, - conditionsHash: result.conditionsHash, - deterministicProofHash: result.deterministicProofHash, - auditEventHash: result.auditEventHash, - winnerIds: result.winnerIds as any, - reserveWinnerIds: result.reserveWinnerIds as any, - eligibleCount: result.totalEligibleCount, - drawId: result.drawId, - drawnAt: new Date(result.drawnAt), - verifiedAt: new Date(), - }, - }); + if (updatedStatus.count === 0) { + throw new ConflictError( + `Cannot draw giveaway "${id}": giveaway is not in SNAPSHOT_LOCKED status or has already been drawn` + ); + } - // 3. Update Giveaway status to DRAWN - await tx.giveaway.update({ - where: { id }, - data: { - status: 'DRAWN', - drawnAt: new Date(result.drawnAt), - seed: result.seedUsed, - }, + // 2. Create DrawResult with unique constraint on giveawayId and snapshotId + await tx.drawResult.create({ + data: { + drawId: result.drawId, + giveawayId: id, + snapshotId: snapshotId, + winners: result.winners as any, + reserveWinners: result.reserveWinners as any, + winnerIds: result.winnerIds as any, + reserveWinnerIds: result.reserveWinnerIds as any, + totalEligibleCount: result.totalEligibleCount, + totalLoadedCount: result.totalLoadedCount, + seedUsed: result.seedUsed, + algorithmVersion: result.algorithmVersion, + deterministicProofHash: result.deterministicProofHash, + auditEventHash: result.auditEventHash, + drawnAt: new Date(result.drawnAt), + }, + }); + + // 3. Create AuditRecord + await tx.auditRecord.create({ + data: { + giveawayId: id, + snapshotId: snapshotId, + algorithmVersion: result.algorithmVersion, + seed: result.seedUsed, + participantsSnapshotHash: result.participantsSnapshotHash, + conditionsHash: result.conditionsHash, + deterministicProofHash: result.deterministicProofHash, + auditEventHash: result.auditEventHash, + winnerIds: result.winnerIds as any, + reserveWinnerIds: result.reserveWinnerIds as any, + eligibleCount: result.totalEligibleCount, + drawId: result.drawId, + drawnAt: new Date(result.drawnAt), + verifiedAt: new Date(), + }, + }); }); - }); + } catch (err: any) { + if (err instanceof ConflictError) throw err; + if (err?.code === 'P2002') { + throw new ConflictError(`Giveaway "${id}" has already been drawn.`); + } + throw err; + } const updated = await this.getGiveawayById(id); return updated!; diff --git a/src/providers/factory.ts b/src/providers/factory.ts new file mode 100644 index 0000000..424d303 --- /dev/null +++ b/src/providers/factory.ts @@ -0,0 +1,29 @@ +import { SocialMediaProvider } from './types'; +import { VkProvider } from './vk/vk-provider'; +import { VkMockProvider } from './vk/vk-mock-provider'; +import { DependencyUnavailableError } from '../core/errors/http-errors'; + +export class ProviderFactory { + public static getVkProvider(): SocialMediaProvider { + // 1. Explicit mock configuration + if (process.env.USE_VK_MOCK === 'true') { + return new VkMockProvider(); + } + + // 2. Real provider when token is present + const serviceToken = process.env.VK_SERVICE_TOKEN; + if (serviceToken && serviceToken.trim().length > 0) { + return new VkProvider(serviceToken.trim()); + } + + // 3. Test environment fallback + if (process.env.NODE_ENV === 'test') { + return new VkMockProvider(); + } + + // 4. Production/Default without token: STRICT FAIL + throw new DependencyUnavailableError( + 'VK provider credentials are not configured. Configure VK_SERVICE_TOKEN or set USE_VK_MOCK=true for staging/test.' + ); + } +} diff --git a/tests/api-validation.test.ts b/tests/api-validation.test.ts index dd9e01e..e0da31d 100644 --- a/tests/api-validation.test.ts +++ b/tests/api-validation.test.ts @@ -1,303 +1,90 @@ -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 { describe, it, expect } from 'vitest'; +import { + createGiveawaySchema, + executeDrawSchema, + validateProviderCapabilities +} from '../src/core/validation/giveaway-schemas'; +import { ValidationError } from '../src/core/errors/http-errors'; 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, - }); -} +describe('Zod API Validation & Capability Rules', () => { + it('should accept valid executeDraw payload', () => { + const valid = executeDrawSchema.parse({ + winnersCount: 5, + reserveWinnersCount: 2, + seed: 'valid-custom-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(); + expect(valid.winnersCount).toBe(5); + expect(valid.reserveWinnersCount).toBe(2); }); - 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); - }); + it('should reject winnersCount outside 1..100', () => { + expect(() => executeDrawSchema.parse({ winnersCount: 0 })).toThrow(); + expect(() => executeDrawSchema.parse({ winnersCount: 101 })).toThrow(); + expect(() => executeDrawSchema.parse({ winnersCount: -5 })).toThrow(); }); - 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); - }); + it('should reject reserveWinnersCount outside 0..100', () => { + expect(() => executeDrawSchema.parse({ reserveWinnersCount: -1 })).toThrow(); + expect(() => executeDrawSchema.parse({ reserveWinnersCount: 105 })).toThrow(); }); - 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); - }); + it('should reject seed longer than 512 characters', () => { + const oversizedSeed = 'a'.repeat(513); + expect(() => executeDrawSchema.parse({ seed: oversizedSeed })).toThrow(); }); - 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); - }); + it('should reject URL longer than 2048 characters in createGiveaway', () => { + const longUrl = 'https://vk.com/wall-1_1?' + 'x'.repeat(2100); + expect(() => + createGiveawaySchema.parse({ + sourceUrl: longUrl, + post: { + platform: 'VK', + ownerId: '-1', + postId: '1', + sourceUrl: 'https://vk.com/wall-1_1', + title: 'Title', + likesCount: 0, + commentsCount: 0, + repostsCount: 0, + }, + }) + ).toThrow(); }); - 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); - }); + it('should throw ValidationError when unsupported repost condition is requested', () => { + const vkCapabilities = { + likes: true, + comments: true, + reposts: false, + subscriptions: true, + adminDetection: false, + }; + + expect(() => + validateProviderCapabilities( + { ...DEFAULT_FILTER_RULES, requireRepost: true }, + vkCapabilities + ) + ).toThrow(ValidationError); }); - 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); - }); + it('should throw ValidationError when admin detection is requested without capability', () => { + const vkCapabilities = { + likes: true, + comments: true, + reposts: false, + subscriptions: true, + adminDetection: false, + }; + + expect(() => + validateProviderCapabilities( + { ...DEFAULT_FILTER_RULES, excludeAdmins: true }, + vkCapabilities + ) + ).toThrow(ValidationError); }); }); diff --git a/tests/concurrency-draw.test.ts b/tests/concurrency-draw.test.ts new file mode 100644 index 0000000..0f0f472 --- /dev/null +++ b/tests/concurrency-draw.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic'; +import { ConflictError } from '../src/core/errors/http-errors'; + +describe('Concurrency Double Draw Protection', () => { + const participants: FilteredParticipant[] = Array.from({ length: 20 }, (_, i) => ({ + platformUserId: `user_${i + 1}`, + firstName: `User`, + lastName: `${i + 1}`, + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + })); + + it('should allow exactly 1 success out of 20 concurrent draw requests, with 19 receiving 409 Conflict', async () => { + const repo = new MemoryGiveawayRepository(); + + // 1. Create giveaway & lock snapshot + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-100_500', + post: { + platform: 'VK', + ownerId: '-100', + postId: '500', + sourceUrl: 'https://vk.com/wall-100_500', + title: 'Concurrent Test Giveaway', + text: 'Description', + likesCount: 100, + commentsCount: 20, + repostsCount: 10, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); + + // 2. Launch 20 concurrent draw attempts + const concurrentDrawPromises = Array.from({ length: 20 }, async (_, index) => { + try { + const seed = `concurrent-seed-${index}`; + const drawResult = executeDeterministicDrawV1({ + giveawayId: gw.id, + snapshot, + totalLoadedCount: 20, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const saved = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult); + return { status: 200, success: true, result: saved }; + } catch (err: any) { + if (err instanceof ConflictError || err?.message?.includes('already been drawn') || err?.message?.includes('SNAPSHOT_LOCKED')) { + return { status: 409, success: false, error: err.message }; + } + return { status: 500, success: false, error: err.message }; + } + }); + + const results = await Promise.all(concurrentDrawPromises); + + const successCount = results.filter(r => r.status === 200).length; + const conflictCount = results.filter(r => r.status === 409).length; + const serverErrorCount = results.filter(r => r.status === 500).length; + + expect(successCount).toBe(1); + expect(conflictCount).toBe(19); + expect(serverErrorCount).toBe(0); + + // Verify giveaway is in DRAWN state with exactly 1 draw result + const finalized = await repo.getGiveawayById(gw.id); + expect(finalized?.status).toBe('DRAWN'); + expect(finalized?.drawResult).toBeDefined(); + expect(finalized?.drawResult?.winners.length).toBe(1); + }); +}); diff --git a/tests/concurrency-snapshot.test.ts b/tests/concurrency-snapshot.test.ts new file mode 100644 index 0000000..4c9d0fb --- /dev/null +++ b/tests/concurrency-snapshot.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ConflictError } from '../src/core/errors/http-errors'; + +describe('Concurrency Snapshot Locking & Participant Isolation', () => { + const participants: FilteredParticipant[] = [ + { + platformUserId: '101', + firstName: 'Участник', + lastName: '1', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '102', + firstName: 'Участник', + lastName: '2', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + it('should not allow locking snapshot when status is DRAWN', async () => { + const repo = new MemoryGiveawayRepository(); + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-1_1', + post: { + platform: 'VK', + ownerId: '-1', + postId: '1', + sourceUrl: 'https://vk.com/wall-1_1', + title: 'Title', + text: 'Text', + likesCount: 10, + commentsCount: 2, + repostsCount: 1, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + // Valid transition: READY -> SNAPSHOT_LOCKED -> DRAWN + await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); + await repo.updateStatus(gw.id, 'DRAWN'); + + await expect( + repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES) + ).rejects.toThrow(ConflictError); + }); + + it('should not allow modifying participants when status is SNAPSHOT_LOCKED', async () => { + const repo = new MemoryGiveawayRepository(); + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-1_2', + post: { + platform: 'VK', + ownerId: '-1', + postId: '2', + sourceUrl: 'https://vk.com/wall-1_2', + title: 'Title', + text: 'Text', + likesCount: 10, + commentsCount: 2, + repostsCount: 1, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); + + await expect( + repo.saveParticipants(gw.id, participants) + ).rejects.toThrow(/Cannot modify participants/); + }); +}); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts new file mode 100644 index 0000000..1e3834f --- /dev/null +++ b/tests/idempotency.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { IdempotencyStore } from '../src/lib/idempotency'; + +describe('Idempotency Key Store', () => { + it('should store and return cached idempotent response', () => { + const key = 'test-idemp-key-1'; + const payload = { result: 'ok', id: '123' }; + + expect(IdempotencyStore.get(key)).toBeNull(); + + IdempotencyStore.set(key, 201, payload); + + const cached = IdempotencyStore.get(key); + expect(cached).not.toBeNull(); + expect(cached?.statusCode).toBe(201); + expect(cached?.body).toEqual(payload); + }); +}); diff --git a/tests/payload-scalability.test.ts b/tests/payload-scalability.test.ts new file mode 100644 index 0000000..57fd4f1 --- /dev/null +++ b/tests/payload-scalability.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; + +describe('Payload Scalability & Pagination', () => { + const participants: FilteredParticipant[] = Array.from({ length: 120 }, (_, i) => ({ + platformUserId: `${1000 + i}`, + firstName: `User`, + lastName: `${i + 1}`, + source: 'LIKES', + liked: true, + commented: i % 2 === 0, + commentsCount: i % 2 === 0 ? 1 : 0, + reposted: false, + subscribed: true, + eligible: i % 3 !== 0, // 80 eligible, 40 excluded + exclusionReason: i % 3 === 0 ? 'Not eligible' : null, + })); + + it('listGiveawaysSummary should return lightweight objects without raw participants or snapshots arrays', async () => { + const repo = new MemoryGiveawayRepository(); + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-1_100', + post: { + platform: 'VK', + ownerId: '-1', + postId: '100', + sourceUrl: 'https://vk.com/wall-1_100', + title: 'Scalability Test', + text: 'Text', + likesCount: 120, + commentsCount: 60, + repostsCount: 10, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + await repo.saveParticipants(gw.id, participants); + + const summaries = await repo.listGiveawaysSummary(); + expect(summaries.length).toBe(1); + + const summary = summaries[0]; + expect(summary.id).toBe(gw.id); + expect(summary.totalParticipantsCount).toBe(120); + // Ensure raw heavy fields are not present in summary + expect((summary as any).participants).toBeUndefined(); + expect((summary as any).snapshots).toBeUndefined(); + }); + + it('getParticipantsPaginated should correctly paginate and filter tabs', async () => { + const repo = new MemoryGiveawayRepository(); + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-1_200', + post: { + platform: 'VK', + ownerId: '-1', + postId: '200', + sourceUrl: 'https://vk.com/wall-1_200', + title: 'Pagination Test', + text: 'Text', + likesCount: 120, + commentsCount: 60, + repostsCount: 10, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + await repo.saveParticipants(gw.id, participants); + + // Page 1: 50 items + const page1 = await repo.getParticipantsPaginated(gw.id, 1, 50, 'all'); + expect(page1.participants.length).toBe(50); + expect(page1.totalCount).toBe(120); + expect(page1.totalPages).toBe(3); + + // Page 3: 20 items + const page3 = await repo.getParticipantsPaginated(gw.id, 3, 50, 'all'); + expect(page3.participants.length).toBe(20); + + // Tab 'eligible': 80 items total -> 50 on page 1, 30 on page 2 + const eligiblePage1 = await repo.getParticipantsPaginated(gw.id, 1, 50, 'eligible'); + expect(eligiblePage1.participants.length).toBe(50); + expect(eligiblePage1.totalPages).toBe(2); + expect(eligiblePage1.participants.every(p => p.eligible)).toBe(true); + + const eligiblePage2 = await repo.getParticipantsPaginated(gw.id, 2, 50, 'eligible'); + expect(eligiblePage2.participants.length).toBe(30); + }); +}); diff --git a/tests/provider-safety.test.ts b/tests/provider-safety.test.ts new file mode 100644 index 0000000..6dbb4f2 --- /dev/null +++ b/tests/provider-safety.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ProviderFactory } from '../src/providers/factory'; +import { VkMockProvider } from '../src/providers/vk/vk-mock-provider'; +import { VkProvider } from '../src/providers/vk/vk-provider'; +import { DependencyUnavailableError } from '../src/core/errors/http-errors'; + +describe('Provider Safety (No unconfigured mocks in Production)', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.USE_VK_MOCK; + delete process.env.VK_SERVICE_TOKEN; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('should return VkMockProvider when USE_VK_MOCK=true is explicitly set', () => { + process.env.USE_VK_MOCK = 'true'; + const provider = ProviderFactory.getVkProvider(); + expect(provider).toBeInstanceOf(VkMockProvider); + }); + + it('should return VkProvider when VK_SERVICE_TOKEN is present', () => { + process.env.VK_SERVICE_TOKEN = 'mock_service_token_123'; + const provider = ProviderFactory.getVkProvider(); + expect(provider).toBeInstanceOf(VkProvider); + }); + + it('should throw DependencyUnavailableError in production when VK credentials and USE_VK_MOCK are missing', () => { + process.env.NODE_ENV = 'production'; + delete process.env.USE_VK_MOCK; + delete process.env.VK_SERVICE_TOKEN; + + expect(() => ProviderFactory.getVkProvider()).toThrow(DependencyUnavailableError); + }); +}); diff --git a/tests/rate-limiter.test.ts b/tests/rate-limiter.test.ts new file mode 100644 index 0000000..8e09d91 --- /dev/null +++ b/tests/rate-limiter.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { SlidingWindowRateLimiter } from '../src/lib/rate-limiter'; +import { RateLimitError } from '../src/core/errors/http-errors'; + +describe('Sliding Window Rate Limiter', () => { + it('should allow requests within limit and block when threshold exceeded', () => { + const limiter = new SlidingWindowRateLimiter({ + windowMs: 1000, + maxRequests: 3, + }); + + const key = 'test-user-ip'; + + expect(limiter.check(key).allowed).toBe(true); + expect(limiter.check(key).allowed).toBe(true); + expect(limiter.check(key).allowed).toBe(true); + + const fourth = limiter.check(key); + expect(fourth.allowed).toBe(false); + expect(fourth.remaining).toBe(0); + + expect(() => limiter.assertAllowed(key)).toThrow(RateLimitError); + }); +});