-
-
- CSPRNG / crypto.randomBytes
-
+
+
setSeed(e.target.value)}
- className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-mono text-xs focus:outline-none focus:border-blue-500"
+ className="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500 font-mono text-xs"
/>
-
- Если seed не задан вручную, система сгенерирует 128-битный криптографический ключ Node.js CSPRNG
-
diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts
index a4c15cc..b8b42d8 100644
--- a/src/lib/repository/memory-repository.ts
+++ b/src/lib/repository/memory-repository.ts
@@ -156,7 +156,12 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
const gw = this.giveaways.get(id);
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
- GiveawayFSM.assertCanModifyParticipants(gw.status);
+ if (gw.status !== 'READY') {
+ throw new ConflictError(
+ `Cannot modify participants: giveaway "${id}" is in status "${gw.status}", but requires "READY"`
+ );
+ }
+
gw.participants = participants;
gw.status = 'READY';
gw.updatedAt = new Date().toISOString();
diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts
index 81081df..9e583cb 100644
--- a/src/lib/repository/prisma-repository.ts
+++ b/src/lib/repository/prisma-repository.ts
@@ -1,11 +1,11 @@
-import { prisma } from '../prisma';
import {
IGiveawayRepository,
CreateGiveawayInput,
- GiveawayWithRelations,
+ GiveawayWithRelations,
GiveawaySummary,
PaginatedParticipantsResult
} from './giveaway-repository';
+import { prisma } from '../prisma';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
import { FilteredParticipant } from '../../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
@@ -36,28 +36,21 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
giveawayId: s.giveawayId,
version: s.version,
createdAt: s.createdAt.toISOString(),
- eligibleParticipants: s.eligibleParticipants as FilteredParticipant[],
- filterRulesSnapshot: s.filterRulesSnapshot as FilterRules,
+ eligibleParticipants: (s.eligibleParticipants as any) || [],
+ filterRulesSnapshot: (s.filterRulesSnapshot as any) || {},
participantCount: s.participantCount,
participantsSnapshotHash: s.participantsSnapshotHash,
conditionsHash: s.conditionsHash,
}));
- const latestSnapshot = snapshots.length > 0
- ? [...snapshots].sort((a, b) => b.version - a.version)[0]
- : null;
+ const latestSnapshot = snapshots.length > 0 ? snapshots[0] : null;
let drawResult: DrawExecutionResult | null = null;
if (raw.drawResult) {
- const boundSnapshot = raw.drawResult.snapshot
- ? {
- participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash,
- conditionsHash: raw.drawResult.snapshot.conditionsHash,
- }
- : snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot;
+ const boundSnapshot = snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot;
drawResult = {
- drawId: raw.drawResult.drawId || raw.drawResult.id,
+ drawId: raw.drawResult.drawId,
giveawayId: raw.drawResult.giveawayId,
snapshotId: raw.drawResult.snapshotId,
winners: raw.drawResult.winners as any,
@@ -67,12 +60,12 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
totalEligibleCount: raw.drawResult.totalEligibleCount,
totalLoadedCount: raw.drawResult.totalLoadedCount,
seedUsed: raw.drawResult.seedUsed,
- participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '',
- conditionsHash: boundSnapshot?.conditionsHash || '',
- algorithmVersion: raw.drawResult.algorithmVersion,
+ algorithmVersion: raw.drawResult.algorithmVersion as any,
deterministicProofHash: raw.drawResult.deterministicProofHash,
auditEventHash: raw.drawResult.auditEventHash,
drawnAt: raw.drawResult.drawnAt.toISOString(),
+ participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '',
+ conditionsHash: boundSnapshot?.conditionsHash || '',
};
}
@@ -89,7 +82,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
postCommentsCount: raw.postCommentsCount,
postRepostsCount: raw.postRepostsCount,
status: raw.status as GiveawayStatusType,
- filterRules: raw.filterRules as FilterRules,
+ filterRules: raw.filterRules as any,
winnersCount: raw.winnersCount,
reserveWinnersCount: raw.reserveWinnersCount,
seed: raw.seed,
@@ -300,12 +293,29 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
}
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise {
- const current = await this.getGiveawayById(id);
- if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
-
- GiveawayFSM.assertCanModifyParticipants(current.status);
-
await prisma.$transaction(async (tx) => {
+ // Atomic conditional status guard: only allow replacing participants if status is READY
+ const updateRes = await tx.giveaway.updateMany({
+ where: {
+ id,
+ status: 'READY',
+ },
+ data: {
+ status: 'READY',
+ updatedAt: new Date(),
+ },
+ });
+
+ if (updateRes.count === 0) {
+ const check = await tx.giveaway.findUnique({ where: { id } });
+ if (!check) {
+ throw new NotFoundError(`Giveaway with id "${id}" not found`);
+ }
+ throw new ConflictError(
+ `Cannot modify participants: giveaway "${id}" is in status "${check.status}", but requires "READY"`
+ );
+ }
+
await tx.participant.deleteMany({ where: { giveawayId: id } });
if (participants.length > 0) {
@@ -328,11 +338,6 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
})),
});
}
-
- await tx.giveaway.update({
- where: { id },
- data: { status: 'READY' },
- });
});
const updated = await this.getGiveawayById(id);
diff --git a/tests/concurrency-participants-snapshot.test.ts b/tests/concurrency-participants-snapshot.test.ts
new file mode 100644
index 0000000..d34416b
--- /dev/null
+++ b/tests/concurrency-participants-snapshot.test.ts
@@ -0,0 +1,107 @@
+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: Participants Update vs Snapshot Lock Race', () => {
+ const initialParticipants: FilteredParticipant[] = Array.from({ length: 10 }, (_, 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,
+ }));
+
+ const updatedParticipants: FilteredParticipant[] = Array.from({ length: 20 }, (_, i) => ({
+ platformUserId: `user_new_${i + 1}`,
+ firstName: 'NewUser',
+ lastName: `${i + 1}`,
+ source: 'LIKES',
+ liked: true,
+ commented: false,
+ commentsCount: 0,
+ reposted: false,
+ subscribed: true,
+ eligible: true,
+ exclusionReason: null,
+ }));
+
+ it('saveParticipants must fail with ConflictError if snapshot is already locked', async () => {
+ const repo = new MemoryGiveawayRepository();
+ const gw = await repo.createGiveaway({
+ sourceUrl: 'https://vk.com/wall-100_1',
+ post: {
+ platform: 'VK',
+ ownerId: '-100',
+ postId: '1',
+ sourceUrl: 'https://vk.com/wall-100_1',
+ title: 'Race Test',
+ likesCount: 10,
+ commentsCount: 0,
+ repostsCount: 0,
+ },
+ filterRules: DEFAULT_FILTER_RULES,
+ });
+
+ await repo.saveParticipants(gw.id, initialParticipants);
+ expect(gw.status).toBe('READY');
+
+ // 1. Lock snapshot -> moves status to SNAPSHOT_LOCKED
+ await repo.createAndLockSnapshot(gw.id, initialParticipants, DEFAULT_FILTER_RULES);
+ const locked = await repo.getGiveawayById(gw.id);
+ expect(locked?.status).toBe('SNAPSHOT_LOCKED');
+
+ // 2. Attempt to save new participants -> MUST reject with ConflictError
+ await expect(
+ repo.saveParticipants(gw.id, updatedParticipants)
+ ).rejects.toThrow(ConflictError);
+
+ // 3. Status must remain SNAPSHOT_LOCKED (never overwritten back to READY)
+ const finalized = await repo.getGiveawayById(gw.id);
+ expect(finalized?.status).toBe('SNAPSHOT_LOCKED');
+ expect(finalized?.participants.length).toBe(10);
+ });
+
+ it('simultaneous participant update and snapshot lock guarantees consistent single winner state', async () => {
+ const repo = new MemoryGiveawayRepository();
+ const gw = await repo.createGiveaway({
+ sourceUrl: 'https://vk.com/wall-100_2',
+ post: {
+ platform: 'VK',
+ ownerId: '-100',
+ postId: '2',
+ sourceUrl: 'https://vk.com/wall-100_2',
+ title: 'Simultaneous Race Test',
+ likesCount: 10,
+ commentsCount: 0,
+ repostsCount: 0,
+ },
+ filterRules: DEFAULT_FILTER_RULES,
+ });
+
+ await repo.saveParticipants(gw.id, initialParticipants);
+
+ // Launch both simultaneously
+ const results = await Promise.allSettled([
+ repo.createAndLockSnapshot(gw.id, initialParticipants, DEFAULT_FILTER_RULES),
+ repo.saveParticipants(gw.id, updatedParticipants),
+ ]);
+
+ // Check final state integrity
+ const finalGw = await repo.getGiveawayById(gw.id);
+ expect(finalGw).not.toBeNull();
+ expect(['READY', 'SNAPSHOT_LOCKED']).toContain(finalGw?.status);
+
+ if (finalGw?.status === 'SNAPSHOT_LOCKED') {
+ // If snapshot won, participants cannot be the new ones without snapshot binding
+ expect(finalGw.snapshots.length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/tests/payload-summary-regression.test.ts b/tests/payload-summary-regression.test.ts
new file mode 100644
index 0000000..5e2b92c
--- /dev/null
+++ b/tests/payload-summary-regression.test.ts
@@ -0,0 +1,62 @@
+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 { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
+import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
+import { ProviderRegistry } from '../src/providers/registry';
+
+describe('POST /participants Payload Summary Regression Test', () => {
+ beforeEach(() => {
+ GiveawayStore.setRepository(new MemoryGiveawayRepository());
+ ProviderRegistry.useMockVk();
+ });
+
+ it('POST /participants response must return summary only and NOT contain massive participant arrays', async () => {
+ const gw = await GiveawayStore.create({
+ sourceUrl: 'https://vk.com/wall-100_1',
+ post: {
+ platform: 'VK',
+ ownerId: '-100',
+ postId: '1',
+ sourceUrl: 'https://vk.com/wall-100_1',
+ title: 'Large Payload Test',
+ likesCount: 100000,
+ commentsCount: 50000,
+ repostsCount: 0,
+ },
+ filterRules: DEFAULT_FILTER_RULES,
+ winnersCount: 1,
+ reserveWinnersCount: 0,
+ });
+
+ const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
+ method: 'POST',
+ body: JSON.stringify({
+ filterRules: DEFAULT_FILTER_RULES,
+ }),
+ });
+
+ const res = await participantsPost(req, { params: { id: gw.id } });
+ expect(res.status).toBe(200);
+
+ const body = await res.json();
+
+ // Must have summary fields
+ expect(body.success).toBe(true);
+ expect(body.giveawayId).toBe(gw.id);
+ expect(typeof body.totalCount).toBe('number');
+ expect(typeof body.eligibleCount).toBe('number');
+ expect(typeof body.excludedCount).toBe('number');
+
+ // Must NOT contain large participant arrays
+ expect(body.allParticipants).toBeUndefined();
+ expect(body.eligibleParticipants).toBeUndefined();
+ expect(body.excludedParticipants).toBeUndefined();
+ expect(body.participants).toBeUndefined();
+
+ // Payload size must be tiny (< 500 bytes)
+ const jsonString = JSON.stringify(body);
+ expect(jsonString.length).toBeLessThan(500);
+ });
+});