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

This commit is contained in:
Ochenstarik 2026-08-18 01:53:56 +07:00
parent 46bf8aad6e
commit 50973b4f85
26 changed files with 1445 additions and 551 deletions

12
package-lock.json generated
View file

@ -14,7 +14,8 @@
"next": "^14.2.15", "next": "^14.2.15",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4" "tailwind-merge": "^2.5.4",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.16.11", "@types/node": "^20.16.11",
@ -7803,6 +7804,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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"
}
} }
} }
} }

View file

@ -19,7 +19,8 @@
"next": "^14.2.15", "next": "^14.2.15",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4" "tailwind-merge": "^2.5.4",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.16.11", "@types/node": "^20.16.11",

View file

@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; 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 { 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( export async function POST(
req: NextRequest, req: NextRequest,
@ -10,42 +13,35 @@ export async function POST(
) { ) {
try { try {
const { id } = params; 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); const giveaway = await GiveawayStore.getById(id);
if (!giveaway) { 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 // 1. Strict FSM Guard: Draw is permitted ONLY in SNAPSHOT_LOCKED status
if (giveaway.status === 'DRAWN') { GiveawayFSM.assertCanDraw(giveaway.status);
return NextResponse.json({
error: 'Розыгрыш уже проведен. Повторный запуск строго запрещен.',
}, { status: 400 });
}
// 2. Fetch locked snapshot (or lock current eligible if ready) // 2. Strict Snapshot requirement: Never create a snapshot implicitly
let snapshot = await GiveawayStore.getLatestSnapshot(id); const snapshot = giveaway.latestSnapshot;
if (!snapshot) { if (!snapshot) {
const eligible = giveaway.participants.filter(p => p.eligible); throw new ConflictError(
if (eligible.length === 0) { 'Cannot execute draw: no locked participant snapshot exists. Lock a snapshot before drawing.'
return NextResponse.json({ );
error: 'Нет допущенных участников для создания слепка и розыгрыша'
}, { status: 400 });
}
snapshot = await GiveawayStore.createAndLockSnapshot(id, eligible, giveaway.filterRules);
} }
// Validate status after snapshot lock const rawBody = await req.json().catch(() => ({}));
GiveawayFSM.assertCanDraw('SNAPSHOT_LOCKED'); const validated = executeDrawSchema.parse(rawBody);
const winnersCount = body.winnersCount || giveaway.winnersCount || 1; const winnersCount = validated.winnersCount;
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0; const reserveWinnersCount = validated.reserveWinnersCount;
// Seed must be generated with CSPRNG if not provided // Use CSPRNG crypto.randomBytes seed if none provided (Math.random is strictly forbidden)
const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed(); const seed = (validated.seed && validated.seed.trim()) || generateCryptoSecureSeed();
// 3. Execute Provably Fair Randomizer V1 // 3. Execute Provably Fair Fisher-Yates Draw V1
const drawResult = executeDeterministicDrawV1({ const drawResult = executeDeterministicDrawV1({
giveawayId: id, giveawayId: id,
snapshot, snapshot,
@ -56,15 +52,15 @@ export async function POST(
filterRules: giveaway.filterRules, 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); const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
drawResult,
giveaway: updatedGiveaway, giveaway: updatedGiveaway,
drawResult,
}); });
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }

View file

@ -1,8 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; import { GiveawayStore } from '@/lib/giveaway-store';
import { ProviderRegistry } from '@/providers/registry'; import { ProviderFactory } from '@/providers/factory';
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher'; 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( export async function POST(
req: NextRequest, req: NextRequest,
@ -10,54 +39,65 @@ export async function POST(
) { ) {
try { try {
const { id } = params; 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); const giveaway = await GiveawayStore.getById(id);
if (!giveaway) { 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 rawBody = await req.json();
const provider = ProviderRegistry.getProvider(giveaway.platform); const validated = fetchParticipantsSchema.parse(rawBody);
// Reject filter rules the selected provider cannot actually verify const provider = ProviderFactory.getVkProvider();
const capabilityCheck = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities); validateProviderCapabilities(validated.filterRules, provider.capabilities);
if (!capabilityCheck.valid) {
return NextResponse.json(
{ error: 'Unsupported filter rules', details: capabilityCheck.errors },
{ status: 400 }
);
}
// 1. Fetch raw participants // Fetch raw participants from social provider
const rawParticipants = await provider.fetchParticipants({ const rawParticipants = await provider.fetchParticipants({
ownerId: giveaway.platformOwnerId, ownerId: giveaway.platformOwnerId,
postId: giveaway.platformPostId, postId: giveaway.platformPostId,
sourceUrl: giveaway.sourceUrl, includeLikes: validated.filterRules.requireLike,
includeLikes: true, includeComments: validated.filterRules.requireComment,
includeComments: rules.requireComment,
includeReposts: rules.requireRepost,
}); });
// 2. Run enrichment pipeline (subscription check + filter engine) // Run participant fetch, enrichment, and filtering pipeline
const filterResult = await executeParticipantPipeline({ const { allParticipants, eligibleParticipants, excludedParticipants } =
rawParticipants, await executeParticipantPipeline({
rules, rawParticipants,
provider, rules: validated.filterRules,
ownerId: giveaway.platformOwnerId, provider,
}); ownerId: giveaway.platformOwnerId,
});
// 3. Save participants into persistent database // Save atomic participant state in store
await GiveawayStore.updateParticipants(id, filterResult.allParticipants); const updated = await GiveawayStore.updateParticipants(id, allParticipants);
return NextResponse.json({ const responseBody = {
success: true, success: true,
stats: filterResult.stats, giveawayId: updated.id,
allParticipants: filterResult.allParticipants, totalCount: allParticipants.length,
eligibleCount: filterResult.eligibleParticipants.length, eligibleCount: eligibleParticipants.length,
excludedCount: filterResult.excludedParticipants.length, excludedCount: excludedParticipants.length,
}); allParticipants,
eligibleParticipants,
excludedParticipants,
};
if (idempotencyKey) {
IdempotencyStore.set(`import-part:${idempotencyKey}`, 200, responseBody);
}
return NextResponse.json(responseBody);
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }

View file

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; import { GiveawayStore } from '@/lib/giveaway-store';
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
import { generalApiRateLimiter } from '@/lib/rate-limiter';
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@ -7,14 +9,17 @@ export async function GET(
) { ) {
try { try {
const { id } = params; const { id } = params;
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
generalApiRateLimiter.assertAllowed(`giveaway-get:${ip}`);
const giveaway = await GiveawayStore.getById(id); const giveaway = await GiveawayStore.getById(id);
if (!giveaway) { 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 }); return NextResponse.json({ success: true, giveaway });
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }

View file

@ -1,5 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; 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( export async function POST(
req: NextRequest, req: NextRequest,
@ -7,33 +13,62 @@ export async function POST(
) { ) {
try { try {
const { id } = params; 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); const giveaway = await GiveawayStore.getById(id);
if (!giveaway) { 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) { if (eligibleParticipants.length === 0) {
return NextResponse.json({ throw new ConflictError('Cannot create snapshot with 0 eligible participants. Check your filter rules.');
error: 'Нельзя создать слепок с 0 допущенными участниками'
}, { status: 400 });
} }
const rules = body.filterRules || giveaway.filterRules; // Atomically create and lock snapshot in database
const snapshot = await GiveawayStore.createAndLockSnapshot( const snapshot = await GiveawayStore.createAndLockSnapshot(
id, id,
eligibleParticipants, eligibleParticipants,
rules validated.filterRules
); );
return NextResponse.json({ const responseBody = {
success: true, success: true,
giveawayId: id,
status: 'SNAPSHOT_LOCKED',
snapshot, snapshot,
}); };
if (idempotencyKey) {
IdempotencyStore.set(`lock-snap:${idempotencyKey}`, 200, responseBody);
}
return NextResponse.json(responseBody);
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }

View file

@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; import { GiveawayStore } from '@/lib/giveaway-store';
import { verifyDrawResult } from '@/core/randomizer/deterministic'; 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( export async function GET(
req: NextRequest, req: NextRequest,
@ -8,17 +10,17 @@ export async function GET(
) { ) {
try { try {
const { id } = params; 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) { if (!giveaway) {
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); throw new NotFoundError(`Giveaway with id "${id}" not found`);
} }
const drawResult = giveaway.drawResult; const drawResult = giveaway.drawResult;
if (!drawResult) { if (!drawResult) {
return NextResponse.json({ throw new ConflictError('Giveaway has not been drawn yet. Nothing to verify.');
error: 'Giveaway has not been drawn yet. Nothing to verify.'
}, { status: 400 });
} }
// Strict snapshot lookup: DO NOT fallback to latestSnapshot // Strict snapshot lookup: DO NOT fallback to latestSnapshot
@ -26,7 +28,11 @@ export async function GET(
if (!snapshot) { if (!snapshot) {
return NextResponse.json({ return NextResponse.json({
error: `Integrity Error: Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`, success: false,
error: {
code: 'INTEGRITY_ERROR',
message: `Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`,
},
verified: false, verified: false,
snapshotFound: false, snapshotFound: false,
}, { status: 404 }); }, { status: 404 });
@ -52,6 +58,7 @@ export async function GET(
}); });
return NextResponse.json({ return NextResponse.json({
success: true,
verified: verification.verified, verified: verification.verified,
giveawayId: id, giveawayId: id,
drawId: drawResult.drawId, drawId: drawResult.drawId,
@ -71,6 +78,6 @@ export async function GET(
drawnAt: drawResult.drawnAt, drawnAt: drawResult.drawnAt,
}); });
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }

View file

@ -1,36 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store'; 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 { try {
const list = await GiveawayStore.listAll(); const ip = req.headers.get('x-forwarded-for') || 'anonymous';
return NextResponse.json({ success: true, giveaways: list }); 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) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const ip = req.headers.get('x-forwarded-for') || 'anonymous';
const { sourceUrl, post, filterRules = DEFAULT_FILTER_RULES, winnersCount = 1, reserveWinnersCount = 0, seed } = body; generalApiRateLimiter.assertAllowed(`giveaway-create:${ip}`);
if (!sourceUrl || !post) { const idempotencyKey = req.headers.get('idempotency-key');
return NextResponse.json({ error: 'sourceUrl and post are required' }, { status: 400 }); 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({ const giveaway = await GiveawayStore.create({
sourceUrl, sourceUrl: validated.sourceUrl,
post, post: validated.post,
filterRules, filterRules: validated.filterRules,
winnersCount, winnersCount: validated.winnersCount,
reserveWinnersCount, reserveWinnersCount: validated.reserveWinnersCount,
seed, 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) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return handleApiError(error);
} }
} }

View file

@ -1,31 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { ProviderRegistry } from '@/providers/registry'; import { ProviderFactory } from '@/providers/factory';
import { PlatformType } from '@/core/types/giveaway'; 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) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const ip = req.headers.get('x-forwarded-for') || 'anonymous';
const { url, platform = 'VK' } = body; generalApiRateLimiter.assertAllowed(`post-preview:${ip}`);
if (!url || typeof url !== 'string') { const rawBody = await req.json();
return NextResponse.json({ error: 'URL is required' }, { status: 400 }); const validated = postPreviewSchema.parse(rawBody);
}
const provider = ProviderRegistry.getProvider(platform as PlatformType); const provider = ProviderFactory.getVkProvider();
const parsed = provider.parsePostUrl(url); const post = await provider.fetchPost(validated.url);
if (!parsed) { return NextResponse.json({
return NextResponse.json({ success: true,
error: 'Неверный формат ссылки на запись VK. Пример: https://vk.com/wall-123456_789' post,
}, { status: 400 }); });
}
const postMetadata = await provider.fetchPost(url);
return NextResponse.json({ success: true, post: postMetadata });
} catch (error: any) { } catch (error: any) {
return NextResponse.json( return handleApiError(error);
{ error: error.message || 'Ошибка при загрузке данных поста' },
{ status: 500 }
);
} }
} }

View file

@ -13,10 +13,10 @@ import {
ArrowRight, ArrowRight,
RefreshCw, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { StoredGiveaway } from '@/lib/giveaway-store'; import { GiveawaySummary } from '@/lib/repository/giveaway-repository';
export default function DashboardPage() { export default function DashboardPage() {
const [giveaways, setGiveaways] = useState<StoredGiveaway[]>([]); const [giveaways, setGiveaways] = useState<GiveawaySummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchGiveaways = async () => { const fetchGiveaways = async () => {
@ -39,7 +39,7 @@ export default function DashboardPage() {
}, []); }, []);
const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length; 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -152,7 +152,7 @@ export default function DashboardPage() {
{gw.title || 'Розыгрыш по записи VK'} {gw.title || 'Розыгрыш по записи VK'}
</h3> </h3>
<p className="text-xs text-slate-400 truncate mt-0.5 max-w-md"> <p className="text-xs text-slate-400 truncate mt-0.5 max-w-md">
{gw.description || gw.sourceUrl} {gw.sourceUrl}
</p> </p>
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-400"> <div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-400">
<span>Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')}</span> <span>Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')}</span>

View file

@ -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 }
);
}

View file

@ -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' }
);
}
}

View file

@ -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 { PrismaGiveawayRepository } from './repository/prisma-repository';
import { MemoryGiveawayRepository } from './repository/memory-repository'; import { MemoryGiveawayRepository } from './repository/memory-repository';
import { FilterRules } from '../core/types/giveaway'; import { FilterRules } from '../core/types/giveaway';
@ -7,7 +13,6 @@ import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audi
export type StoredGiveaway = GiveawayWithRelations; export type StoredGiveaway = GiveawayWithRelations;
// Select initial repository based on explicit STORAGE_DRIVER configuration
function createDefaultRepository(): IGiveawayRepository { function createDefaultRepository(): IGiveawayRepository {
if (process.env.STORAGE_DRIVER === 'memory') { if (process.env.STORAGE_DRIVER === 'memory') {
return new MemoryGiveawayRepository(); return new MemoryGiveawayRepository();
@ -18,9 +23,6 @@ function createDefaultRepository(): IGiveawayRepository {
let activeRepository: IGiveawayRepository = createDefaultRepository(); let activeRepository: IGiveawayRepository = createDefaultRepository();
export class GiveawayStore { export class GiveawayStore {
/**
* Set custom repository (e.g. MemoryGiveawayRepository in tests)
*/
static setRepository(repo: IGiveawayRepository): void { static setRepository(repo: IGiveawayRepository): void {
activeRepository = repo; activeRepository = repo;
} }
@ -29,9 +31,6 @@ export class GiveawayStore {
return activeRepository; return activeRepository;
} }
/**
* Reset repository to environment default
*/
static resetToDefault(): void { static resetToDefault(): void {
activeRepository = createDefaultRepository(); activeRepository = createDefaultRepository();
} }
@ -48,6 +47,19 @@ export class GiveawayStore {
return await activeRepository.listGiveaways(); return await activeRepository.listGiveaways();
} }
static async listSummaries(): Promise<GiveawaySummary[]> {
return await activeRepository.listGiveawaysSummary();
}
static async getParticipantsPaginated(
id: string,
page: number = 1,
pageSize: number = 50,
tab: 'all' | 'eligible' | 'excluded' = 'all'
): Promise<PaginatedParticipantsResult> {
return await activeRepository.getParticipantsPaginated(id, page, pageSize, tab);
}
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> { static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
return await activeRepository.saveParticipants(id, participants); return await activeRepository.saveParticipants(id, participants);
} }

34
src/lib/idempotency.ts Normal file
View file

@ -0,0 +1,34 @@
interface IdempotentResponse {
statusCode: number;
body: any;
createdAt: number;
}
export class IdempotencyStore {
private static store = new Map<string, IdempotentResponse>();
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();
}
}

70
src/lib/rate-limiter.ts Normal file
View file

@ -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<string, RateLimitRecord>();
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,
});

View file

@ -1,16 +1,7 @@
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../../core/types/giveaway'; 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'; 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 { export interface GiveawayWithRelations {
id: string; id: string;
platform: PlatformType; platform: PlatformType;
@ -33,17 +24,74 @@ export interface GiveawayWithRelations {
drawnAt: string | null; drawnAt: string | null;
participants: FilteredParticipant[]; participants: FilteredParticipant[];
snapshots: ParticipantSnapshotData[]; snapshots: ParticipantSnapshotData[];
latestSnapshot?: ParticipantSnapshotData | null; latestSnapshot: ParticipantSnapshotData | null;
drawResult?: DrawExecutionResult | 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 { export interface IGiveawayRepository {
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>; createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>; getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
listGiveaways(): Promise<GiveawayWithRelations[]>; listGiveaways(): Promise<GiveawayWithRelations[]>;
listGiveawaysSummary(): Promise<GiveawaySummary[]>;
getParticipantsPaginated(
id: string,
page: number,
pageSize: number,
tab?: 'all' | 'eligible' | 'excluded'
): Promise<PaginatedParticipantsResult>;
updateStatus(id: string, status: GiveawayStatusType): Promise<GiveawayWithRelations>; updateStatus(id: string, status: GiveawayStatusType): Promise<GiveawayWithRelations>;
saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations>; saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations>;
createAndLockSnapshot(id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules): Promise<ParticipantSnapshotData>; createAndLockSnapshot(
id: string,
eligibleParticipants: FilteredParticipant[],
rules: FilterRules
): Promise<ParticipantSnapshotData>;
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>; getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
saveDrawResultAndAudit(id: string, snapshotId: string, result: DrawExecutionResult): Promise<GiveawayWithRelations>; saveDrawResultAndAudit(
id: string,
snapshotId: string,
result: DrawExecutionResult
): Promise<GiveawayWithRelations>;
} }

View file

@ -1,17 +1,21 @@
import { import {
IGiveawayRepository, IGiveawayRepository,
CreateGiveawayInput, CreateGiveawayInput,
GiveawayWithRelations GiveawayWithRelations,
GiveawaySummary,
PaginatedParticipantsResult
} from './giveaway-repository'; } from './giveaway-repository';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
import { FilteredParticipant } from '../../core/types/participant'; import { FilteredParticipant } from '../../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit'; import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical'; import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm'; import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
import { ConflictError, NotFoundError } from '../../core/errors/http-errors';
export class MemoryGiveawayRepository implements IGiveawayRepository { export class MemoryGiveawayRepository implements IGiveawayRepository {
private giveaways: Map<string, GiveawayWithRelations> = new Map(); private giveaways: Map<string, GiveawayWithRelations> = new Map();
private snapshots: Map<string, ParticipantSnapshotData[]> = new Map(); private snapshots: Map<string, ParticipantSnapshotData[]> = new Map();
private drawLocks: Set<string> = new Set();
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> { async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
const id = 'gw_' + Math.random().toString(36).slice(2, 10); const id = 'gw_' + Math.random().toString(36).slice(2, 10);
@ -24,7 +28,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
platformOwnerId: input.post.ownerId, platformOwnerId: input.post.ownerId,
platformPostId: input.post.postId, platformPostId: input.post.postId,
title: input.post.title, title: input.post.title,
description: input.post.text, description: input.post.text || null,
postImageUrl: input.post.imageUrl || null, postImageUrl: input.post.imageUrl || null,
postLikesCount: input.post.likesCount, postLikesCount: input.post.likesCount,
postCommentsCount: input.post.commentsCount, postCommentsCount: input.post.commentsCount,
@ -57,7 +61,6 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
let drawResult = gw.drawResult; let drawResult = gw.drawResult;
if (drawResult) { if (drawResult) {
// Strictly bind to the snapshot referenced by snapshotId
const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest; const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest;
drawResult = { drawResult = {
...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()); return all.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
} }
async listGiveawaysSummary(): Promise<GiveawaySummary[]> {
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<PaginatedParticipantsResult> {
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<GiveawayWithRelations> { async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
const gw = await this.getGiveawayById(id); const gw = this.giveaways.get(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`); if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
GiveawayFSM.validateTransition(gw.status, newStatus); GiveawayFSM.validateTransition(gw.status, newStatus);
gw.status = newStatus; gw.status = newStatus;
gw.updatedAt = new Date().toISOString(); gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return gw; return gw;
} }
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> { async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
const gw = await this.getGiveawayById(id); const gw = this.giveaways.get(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`); if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
GiveawayFSM.assertCanModifyParticipants(gw.status); GiveawayFSM.assertCanModifyParticipants(gw.status);
gw.participants = participants; gw.participants = participants;
gw.status = 'READY'; gw.status = 'READY';
gw.updatedAt = new Date().toISOString(); gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return gw; return gw;
} }
@ -107,15 +168,15 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
eligibleParticipants: FilteredParticipant[], eligibleParticipants: FilteredParticipant[],
rules: FilterRules rules: FilterRules
): Promise<ParticipantSnapshotData> { ): Promise<ParticipantSnapshotData> {
const gw = await this.getGiveawayById(id); const gw = this.giveaways.get(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`); if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') { 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) { 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 participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
@ -144,7 +205,6 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
gw.filterRules = rules; gw.filterRules = rules;
gw.latestSnapshot = snapshot; gw.latestSnapshot = snapshot;
gw.updatedAt = new Date().toISOString(); gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return snapshot; return snapshot;
} }
@ -159,17 +219,30 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
snapshotId: string, snapshotId: string,
result: DrawExecutionResult result: DrawExecutionResult
): Promise<GiveawayWithRelations> { ): Promise<GiveawayWithRelations> {
const gw = await this.getGiveawayById(id); // Atomic test & set lock check
if (!gw) throw new Error(`Giveaway with id "${id}" not found`); 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.drawResult = result;
gw.status = 'DRAWN'; gw.status = 'DRAWN';
gw.drawnAt = result.drawnAt; gw.drawnAt = result.drawnAt;
gw.seed = result.seedUsed; gw.seed = result.seedUsed;
gw.updatedAt = new Date().toISOString(); gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return gw; return gw;
} }

View file

@ -2,13 +2,16 @@ import { prisma } from '../prisma';
import { import {
IGiveawayRepository, IGiveawayRepository,
CreateGiveawayInput, CreateGiveawayInput,
GiveawayWithRelations GiveawayWithRelations,
GiveawaySummary,
PaginatedParticipantsResult
} from './giveaway-repository'; } from './giveaway-repository';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway'; import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
import { FilteredParticipant } from '../../core/types/participant'; import { FilteredParticipant } from '../../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit'; import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical'; import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm'; import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
import { ConflictError, NotFoundError } from '../../core/errors/http-errors';
export class PrismaGiveawayRepository implements IGiveawayRepository { export class PrismaGiveawayRepository implements IGiveawayRepository {
private mapPrismaGiveaway(raw: any): GiveawayWithRelations { private mapPrismaGiveaway(raw: any): GiveawayWithRelations {
@ -46,7 +49,6 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
let drawResult: DrawExecutionResult | null = null; let drawResult: DrawExecutionResult | null = null;
if (raw.drawResult) { if (raw.drawResult) {
// Strictly bind to the snapshot attached to drawResult
const boundSnapshot = raw.drawResult.snapshot const boundSnapshot = raw.drawResult.snapshot
? { ? {
participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash, participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash,
@ -166,9 +168,119 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
return list.map(item => this.mapPrismaGiveaway(item)); return list.map(item => this.mapPrismaGiveaway(item));
} }
async listGiveawaysSummary(): Promise<GiveawaySummary[]> {
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<PaginatedParticipantsResult> {
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<GiveawayWithRelations> { async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
const current = await this.getGiveawayById(id); 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); GiveawayFSM.validateTransition(current.status, newStatus);
@ -189,7 +301,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> { async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
const current = await this.getGiveawayById(id); 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); GiveawayFSM.assertCanModifyParticipants(current.status);
@ -233,56 +345,75 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
rules: FilterRules rules: FilterRules
): Promise<ParticipantSnapshotData> { ): Promise<ParticipantSnapshotData> {
const current = await this.getGiveawayById(id); 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') { 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) { 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 participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
const conditionsHash = computeConditionsHash(rules); const conditionsHash = computeConditionsHash(rules);
const latestVersion = current.snapshots.length > 0 try {
? Math.max(...current.snapshots.map(s => s.version)) return await prisma.$transaction(async (tx) => {
: 0; // Atomic status guard
const newVersion = latestVersion + 1; 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([ if (updateRes.count === 0) {
prisma.participantSnapshot.create({ throw new ConflictError(`Concurrent modification or invalid status for giveaway "${id}"`);
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,
},
}),
]);
return { const latestSnap = await tx.participantSnapshot.findFirst({
id: snapshot.id, where: { giveawayId: id },
giveawayId: snapshot.giveawayId, orderBy: { version: 'desc' },
version: snapshot.version, });
createdAt: snapshot.createdAt.toISOString(),
eligibleParticipants: eligibleParticipants, const newVersion = (latestSnap?.version || 0) + 1;
filterRulesSnapshot: rules,
participantCount: snapshot.participantCount, const snapshot = await tx.participantSnapshot.create({
participantsSnapshotHash: snapshot.participantsSnapshotHash, data: {
conditionsHash: snapshot.conditionsHash, 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<ParticipantSnapshotData | null> { async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
@ -312,61 +443,78 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
result: DrawExecutionResult result: DrawExecutionResult
): Promise<GiveawayWithRelations> { ): Promise<GiveawayWithRelations> {
const current = await this.getGiveawayById(id); 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); GiveawayFSM.assertCanDraw(current.status);
await prisma.$transaction(async (tx) => { try {
// 1. Create DrawResult with original drawId await prisma.$transaction(async (tx) => {
await tx.drawResult.create({ // 1. Atomic conditional transition SNAPSHOT_LOCKED -> DRAWN
data: { const updatedStatus = await tx.giveaway.updateMany({
drawId: result.drawId, where: {
giveawayId: id, id,
snapshotId: snapshotId, status: 'SNAPSHOT_LOCKED',
winners: result.winners as any, },
reserveWinners: result.reserveWinners as any, data: {
winnerIds: result.winnerIds as any, status: 'DRAWN',
reserveWinnerIds: result.reserveWinnerIds as any, drawnAt: new Date(result.drawnAt),
totalEligibleCount: result.totalEligibleCount, seed: result.seedUsed,
totalLoadedCount: result.totalLoadedCount, },
seedUsed: result.seedUsed, });
algorithmVersion: result.algorithmVersion,
deterministicProofHash: result.deterministicProofHash,
auditEventHash: result.auditEventHash,
drawnAt: new Date(result.drawnAt),
},
});
// 2. Create AuditRecord if (updatedStatus.count === 0) {
await tx.auditRecord.create({ throw new ConflictError(
data: { `Cannot draw giveaway "${id}": giveaway is not in SNAPSHOT_LOCKED status or has already been drawn`
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(),
},
});
// 3. Update Giveaway status to DRAWN // 2. Create DrawResult with unique constraint on giveawayId and snapshotId
await tx.giveaway.update({ await tx.drawResult.create({
where: { id }, data: {
data: { drawId: result.drawId,
status: 'DRAWN', giveawayId: id,
drawnAt: new Date(result.drawnAt), snapshotId: snapshotId,
seed: result.seedUsed, 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); const updated = await this.getGiveawayById(id);
return updated!; return updated!;

29
src/providers/factory.ts Normal file
View file

@ -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.'
);
}
}

View file

@ -1,303 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect } from 'vitest';
import { NextRequest } from 'next/server'; import {
import { GiveawayStore } from '../src/lib/giveaway-store'; createGiveawaySchema,
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; executeDrawSchema,
import { ProviderRegistry } from '../src/providers/registry'; validateProviderCapabilities
import { POST as giveawaysPost } from '../src/app/api/giveaways/route'; } from '../src/core/validation/giveaway-schemas';
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route'; import { ValidationError } from '../src/core/errors/http-errors';
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 { 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 }> = {}) { describe('Zod API Validation & Capability Rules', () => {
return GiveawayStore.create({ it('should accept valid executeDraw payload', () => {
sourceUrl: 'https://vk.com/wall-100_1', const valid = executeDrawSchema.parse({
post: { winnersCount: 5,
platform: 'VK', reserveWinnersCount: 2,
ownerId: '-100', seed: 'valid-custom-seed',
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) => ({ expect(valid.winnersCount).toBe(5);
platformUserId: `${1000 + i}`, expect(valid.reserveWinnersCount).toBe(2);
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('should reject winnersCount outside 1..100', () => {
it('returns 400 when sourceUrl is missing', async () => { expect(() => executeDrawSchema.parse({ winnersCount: 0 })).toThrow();
const req = new NextRequest('http://localhost/api/giveaways', { expect(() => executeDrawSchema.parse({ winnersCount: 101 })).toThrow();
method: 'POST', expect(() => executeDrawSchema.parse({ winnersCount: -5 })).toThrow();
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('should reject reserveWinnersCount outside 0..100', () => {
it('returns 400 for invalid VK URL', async () => { expect(() => executeDrawSchema.parse({ reserveWinnersCount: -1 })).toThrow();
const req = new NextRequest('http://localhost/api/posts/preview', { expect(() => executeDrawSchema.parse({ reserveWinnersCount: 105 })).toThrow();
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('should reject seed longer than 512 characters', () => {
it('returns 404 for non-existent giveaway', async () => { const oversizedSeed = 'a'.repeat(513);
const req = new NextRequest('http://localhost/api/giveaways/does-not-exist/draw', { expect(() => executeDrawSchema.parse({ seed: oversizedSeed })).toThrow();
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('should reject URL longer than 2048 characters in createGiveaway', () => {
it('currently accepts unknown filter rules in body (documented validation gap)', async () => { const longUrl = 'https://vk.com/wall-1_1?' + 'x'.repeat(2100);
const gw = await createGiveaway(); expect(() =>
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, { createGiveawaySchema.parse({
method: 'POST', sourceUrl: longUrl,
body: JSON.stringify({ post: {
filterRules: { platform: 'VK',
...DEFAULT_FILTER_RULES, ownerId: '-1',
unknownRule: true, postId: '1',
anotherBadField: 'x', sourceUrl: 'https://vk.com/wall-1_1',
}, title: 'Title',
}), likesCount: 0,
}); commentsCount: 0,
const res = await participantsPost(req, { params: { id: gw.id } }); repostsCount: 0,
expect(res.status).toBe(200); },
}); })
).toThrow();
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('should throw ValidationError when unsupported repost condition is requested', () => {
it('returns 400 when there are 0 eligible participants', async () => { const vkCapabilities = {
const gw = await createGiveaway(); likes: true,
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' }))); comments: true,
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, { reposts: false,
method: 'POST', subscriptions: true,
body: JSON.stringify({}), adminDetection: false,
}); };
const res = await snapshotPost(req, { params: { id: gw.id } });
expect(res.status).toBe(400); expect(() =>
}); validateProviderCapabilities(
{ ...DEFAULT_FILTER_RULES, requireRepost: true },
vkCapabilities
)
).toThrow(ValidationError);
}); });
describe('GET /api/giveaways/:id', () => { it('should throw ValidationError when admin detection is requested without capability', () => {
it('returns 404 for non-existent giveaway', async () => { const vkCapabilities = {
const req = new NextRequest('http://localhost/api/giveaways/missing'); likes: true,
const res = await giveawayGet(req, { params: { id: 'missing' } }); comments: true,
expect(res.status).toBe(404); reposts: false,
}); subscriptions: true,
adminDetection: false,
};
expect(() =>
validateProviderCapabilities(
{ ...DEFAULT_FILTER_RULES, excludeAdmins: true },
vkCapabilities
)
).toThrow(ValidationError);
}); });
}); });

View file

@ -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);
});
});

View file

@ -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/);
});
});

18
tests/idempotency.test.ts Normal file
View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});