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:
parent
46bf8aad6e
commit
50973b4f85
26 changed files with 1445 additions and 551 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
const winnersCount = validated.winnersCount;
|
||||
const reserveWinnersCount = validated.reserveWinnersCount;
|
||||
|
||||
// Seed must be generated with CSPRNG if not provided
|
||||
const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed();
|
||||
// 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 Randomizer V1
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
// Run participant fetch, enrichment, and filtering pipeline
|
||||
const { allParticipants, eligibleParticipants, excludedParticipants } =
|
||||
await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 +10,17 @@ 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
|
||||
|
|
@ -26,7 +28,11 @@ export async function GET(
|
|||
|
||||
if (!snapshot) {
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
success: true,
|
||||
post,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Ошибка при загрузке данных поста' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StoredGiveaway[]>([]);
|
||||
const [giveaways, setGiveaways] = useState<GiveawaySummary[]>([]);
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
|
|
@ -152,7 +152,7 @@ export default function DashboardPage() {
|
|||
{gw.title || 'Розыгрыш по записи VK'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 truncate mt-0.5 max-w-md">
|
||||
{gw.description || gw.sourceUrl}
|
||||
{gw.sourceUrl}
|
||||
</p>
|
||||
<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>
|
||||
|
|
|
|||
143
src/core/errors/http-errors.ts
Normal file
143
src/core/errors/http-errors.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
92
src/core/validation/giveaway-schemas.ts
Normal file
92
src/core/validation/giveaway-schemas.ts
Normal 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' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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> {
|
||||
return await activeRepository.saveParticipants(id, participants);
|
||||
}
|
||||
|
|
|
|||
34
src/lib/idempotency.ts
Normal file
34
src/lib/idempotency.ts
Normal 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
70
src/lib/rate-limiter.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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<GiveawayWithRelations>;
|
||||
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
|
||||
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>;
|
||||
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>;
|
||||
saveDrawResultAndAudit(id: string, snapshotId: string, result: DrawExecutionResult): Promise<GiveawayWithRelations>;
|
||||
saveDrawResultAndAudit(
|
||||
id: string,
|
||||
snapshotId: string,
|
||||
result: DrawExecutionResult
|
||||
): Promise<GiveawayWithRelations>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, GiveawayWithRelations> = new Map();
|
||||
private snapshots: Map<string, ParticipantSnapshotData[]> = new Map();
|
||||
private drawLocks: Set<string> = new Set();
|
||||
|
||||
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
|
||||
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<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> {
|
||||
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<GiveawayWithRelations> {
|
||||
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<ParticipantSnapshotData> {
|
||||
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<GiveawayWithRelations> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
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<GiveawayWithRelations> {
|
||||
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,26 +345,45 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
rules: FilterRules
|
||||
): Promise<ParticipantSnapshotData> {
|
||||
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({
|
||||
if (updateRes.count === 0) {
|
||||
throw new ConflictError(`Concurrent modification or invalid status for giveaway "${id}"`);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -262,27 +393,27 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
participantsSnapshotHash,
|
||||
conditionsHash,
|
||||
},
|
||||
}),
|
||||
prisma.giveaway.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'SNAPSHOT_LOCKED',
|
||||
filterRules: rules as any,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
giveawayId: snapshot.giveawayId,
|
||||
version: snapshot.version,
|
||||
createdAt: snapshot.createdAt.toISOString(),
|
||||
eligibleParticipants: eligibleParticipants,
|
||||
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> {
|
||||
|
|
@ -312,12 +443,32 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
result: DrawExecutionResult
|
||||
): Promise<GiveawayWithRelations> {
|
||||
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);
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create DrawResult with original drawId
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedStatus.count === 0) {
|
||||
throw new ConflictError(
|
||||
`Cannot draw giveaway "${id}": giveaway is not in SNAPSHOT_LOCKED status or has already been drawn`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Create DrawResult with unique constraint on giveawayId and snapshotId
|
||||
await tx.drawResult.create({
|
||||
data: {
|
||||
drawId: result.drawId,
|
||||
|
|
@ -337,7 +488,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
},
|
||||
});
|
||||
|
||||
// 2. Create AuditRecord
|
||||
// 3. Create AuditRecord
|
||||
await tx.auditRecord.create({
|
||||
data: {
|
||||
giveawayId: id,
|
||||
|
|
@ -356,17 +507,14 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Update Giveaway status to DRAWN
|
||||
await tx.giveaway.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'DRAWN',
|
||||
drawnAt: new Date(result.drawnAt),
|
||||
seed: result.seedUsed,
|
||||
},
|
||||
});
|
||||
});
|
||||
} 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!;
|
||||
|
|
|
|||
29
src/providers/factory.ts
Normal file
29
src/providers/factory.ts
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
describe('Zod API Validation & Capability Rules', () => {
|
||||
it('should accept valid executeDraw payload', () => {
|
||||
const valid = executeDrawSchema.parse({
|
||||
winnersCount: 5,
|
||||
reserveWinnersCount: 2,
|
||||
seed: 'valid-custom-seed',
|
||||
});
|
||||
|
||||
expect(valid.winnersCount).toBe(5);
|
||||
expect(valid.reserveWinnersCount).toBe(2);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should reject reserveWinnersCount outside 0..100', () => {
|
||||
expect(() => executeDrawSchema.parse({ reserveWinnersCount: -1 })).toThrow();
|
||||
expect(() => executeDrawSchema.parse({ reserveWinnersCount: 105 })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject seed longer than 512 characters', () => {
|
||||
const oversizedSeed = 'a'.repeat(513);
|
||||
expect(() => executeDrawSchema.parse({ seed: oversizedSeed })).toThrow();
|
||||
});
|
||||
|
||||
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: '-100',
|
||||
ownerId: '-1',
|
||||
postId: '1',
|
||||
sourceUrl: 'https://vk.com/wall-100_1',
|
||||
title: 'Test',
|
||||
text: 'Test',
|
||||
likesCount: 10,
|
||||
commentsCount: 5,
|
||||
repostsCount: 2,
|
||||
},
|
||||
filterRules: overrides.filterRules || DEFAULT_FILTER_RULES,
|
||||
winnersCount: overrides.winnersCount ?? 1,
|
||||
reserveWinnersCount: overrides.reserveWinnersCount ?? 0,
|
||||
seed: overrides.seed,
|
||||
});
|
||||
}
|
||||
|
||||
const sampleParticipants: FilteredParticipant[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
platformUserId: `${1000 + i}`,
|
||||
firstName: 'User',
|
||||
lastName: `${i}`,
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
sourceUrl: 'https://vk.com/wall-1_1',
|
||||
title: 'Title',
|
||||
likesCount: 0,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
}));
|
||||
|
||||
describe('API input validation', () => {
|
||||
beforeEach(() => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways', () => {
|
||||
it('returns 400 when sourceUrl is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ post: {} }),
|
||||
});
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when post is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceUrl: 'https://vk.com/wall-1_1' }),
|
||||
});
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 500 for malformed JSON body', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: 'not-json',
|
||||
});
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/posts/preview', () => {
|
||||
it('returns 400 for invalid VK URL', async () => {
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: 'https://google.com' }),
|
||||
});
|
||||
const res = await previewPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when URL is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await previewPost(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 500 for malformed JSON', async () => {
|
||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
||||
method: 'POST',
|
||||
body: '{ broken',
|
||||
});
|
||||
const res = await previewPost(req);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways/:id/draw', () => {
|
||||
it('returns 404 for non-existent giveaway', async () => {
|
||||
const req = new NextRequest('http://localhost/api/giveaways/does-not-exist/draw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: 'does-not-exist' } });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 when drawing a giveaway that is already DRAWN', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, sampleParticipants, DEFAULT_FILTER_RULES);
|
||||
|
||||
// First draw
|
||||
const firstReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const firstRes = await drawPost(firstReq, { params: { id: gw.id } });
|
||||
expect(firstRes.status).toBe(200);
|
||||
|
||||
// Second draw attempt
|
||||
const secondReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const secondRes = await drawPost(secondReq, { params: { id: gw.id } });
|
||||
expect(secondRes.status).toBe(400);
|
||||
const data = await secondRes.json();
|
||||
expect(data.error).toMatch(/уже проведен|already drawn/i);
|
||||
});
|
||||
|
||||
it('returns 400 when there are 0 eligible participants', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/Нет допущенных|0 eligible/i);
|
||||
});
|
||||
|
||||
it('currently accepts winnersCount = -1 (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: -1 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
// Current behavior: does not reject negative winnersCount; it caps to pool size.
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.winners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('currently treats winnersCount = 0 as the giveaway default (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 0 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
// The route uses `body.winnersCount || giveaway.winnersCount || 1`, so 0 is ignored.
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.winners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps winnersCount = 999999999 to pool size', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 999999999 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.winners).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('currently accepts reserveWinnersCount < 0 (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reserveWinnersCount: -5 }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.reserveWinners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses generated seed when empty seed is provided', async () => {
|
||||
const gw = await createGiveaway({ seed: undefined });
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: ' ' }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.seedUsed).toBeTruthy();
|
||||
expect(data.drawResult.seedUsed.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('currently accepts huge seed strings (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const hugeSeed = 'a'.repeat(100_000);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: hugeSeed }),
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.drawResult.seedUsed).toBe(hugeSeed);
|
||||
});
|
||||
|
||||
it('currently swallows malformed JSON body and proceeds (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: 'not-json',
|
||||
});
|
||||
const res = await drawPost(req, { params: { id: gw.id } });
|
||||
// `.catch(() => ({}))` silently turns malformed JSON into an empty body.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/giveaways/:id/participants', () => {
|
||||
it('currently accepts unknown filter rules in body (documented validation gap)', async () => {
|
||||
const gw = await createGiveaway();
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
filterRules: {
|
||||
...DEFAULT_FILTER_RULES,
|
||||
unknownRule: true,
|
||||
anotherBadField: 'x',
|
||||
repostsCount: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
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);
|
||||
});
|
||||
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('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 admin detection is requested without capability', () => {
|
||||
const vkCapabilities = {
|
||||
likes: true,
|
||||
comments: true,
|
||||
reposts: false,
|
||||
subscriptions: true,
|
||||
adminDetection: false,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
expect(() =>
|
||||
validateProviderCapabilities(
|
||||
{ ...DEFAULT_FILTER_RULES, excludeAdmins: true },
|
||||
vkCapabilities
|
||||
)
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
84
tests/concurrency-draw.test.ts
Normal file
84
tests/concurrency-draw.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
88
tests/concurrency-snapshot.test.ts
Normal file
88
tests/concurrency-snapshot.test.ts
Normal 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
18
tests/idempotency.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
tests/payload-scalability.test.ts
Normal file
91
tests/payload-scalability.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
tests/provider-safety.test.ts
Normal file
38
tests/provider-safety.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
tests/rate-limiter.test.ts
Normal file
24
tests/rate-limiter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue