From 3f795e23fe6397832147fb47285584dc0eccbf3c Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 02:02:35 +0700 Subject: [PATCH] feat(core): Phase 1.4.1 Final Race & Payload Fix - atomic conditional transition in saveParticipants, summary-only POST /participants response, and 100k payload regression & concurrency race tests --- .../api/giveaways/[id]/participants/route.ts | 4 +- src/app/giveaways/new/page.tsx | 294 +++++++++++------- src/lib/repository/memory-repository.ts | 7 +- src/lib/repository/prisma-repository.ts | 61 ++-- .../concurrency-participants-snapshot.test.ts | 107 +++++++ tests/payload-summary-regression.test.ts | 62 ++++ 6 files changed, 383 insertions(+), 152 deletions(-) create mode 100644 tests/concurrency-participants-snapshot.test.ts create mode 100644 tests/payload-summary-regression.test.ts diff --git a/src/app/api/giveaways/[id]/participants/route.ts b/src/app/api/giveaways/[id]/participants/route.ts index 1471109..66ddb40 100644 --- a/src/app/api/giveaways/[id]/participants/route.ts +++ b/src/app/api/giveaways/[id]/participants/route.ts @@ -81,15 +81,13 @@ export async function POST( // Save atomic participant state in store const updated = await GiveawayStore.updateParticipants(id, allParticipants); + // Return summary only (no massive arrays in POST response) const responseBody = { success: true, giveawayId: updated.id, totalCount: allParticipants.length, eligibleCount: eligibleParticipants.length, excludedCount: excludedParticipants.length, - allParticipants, - eligibleParticipants, - excludedParticipants, }; if (idempotencyKey) { diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index f957d82..d5bc651 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -99,6 +99,33 @@ export default function NewGiveawayWizardPage() { } }; + const [totalCount, setTotalCount] = useState(0); + const [eligibleCount, setEligibleCount] = useState(0); + const [excludedCount, setExcludedCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loadingPage, setLoadingPage] = useState(false); + + const loadParticipantsPage = async (giveawayId: string, page: number, tab: 'all' | 'eligible' | 'excluded') => { + setLoadingPage(true); + try { + const res = await fetch(`/api/giveaways/${giveawayId}/participants?page=${page}&pageSize=50&tab=${tab}`); + const data = await res.json(); + if (res.ok && data.success) { + setParticipants(data.participants || []); + setTotalCount(data.totalCount || 0); + setEligibleCount(data.eligibleCount || 0); + setExcludedCount(data.excludedCount || 0); + setCurrentPage(data.page || 1); + setTotalPages(data.totalPages || 1); + } + } catch (err) { + console.error(err); + } finally { + setLoadingPage(false); + } + }; + // Step 2 handler: Fetch & Enrich Participants const handleFetchParticipants = async () => { if (!createdGiveawayId) return; @@ -120,10 +147,13 @@ export default function NewGiveawayWizardPage() { }); const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Ошибка загрузки участников'); + if (!res.ok) throw new Error(data.error?.message || data.error || 'Ошибка загрузки участников'); - setParticipants(data.allParticipants || []); + setTotalCount(data.totalCount || 0); + setEligibleCount(data.eligibleCount || 0); + setExcludedCount(data.excludedCount || 0); setStep(3); + await loadParticipantsPage(createdGiveawayId, 1, participantTab); } catch (err: any) { alert(err.message); } finally { @@ -549,112 +579,151 @@ export default function NewGiveawayWizardPage() { {/* Filter Tabs */}
{/* Table Container */}
- - - - - - - - - - - - - {displayedParticipants.map((p) => ( - - - - - - - + {loadingPage ? ( +
+ + Загрузка участников... +
+ ) : ( +
УчастникVK IDЛайкКомментПодпискаСтатус
-
- {p.avatarUrl ? ( - - ) : ( - p.firstName[0] - )} -
- - {p.firstName} {p.lastName} - -
- id{p.platformUserId} - - {p.liked ? ( - - ) : ( - - )} - - {p.commented ? ( - {p.commentsCount || 1} - ) : ( - - )} - - {p.subscribed ? ( - - ) : ( - - )} - - {p.eligible ? ( - - - Допущен - - ) : ( - - - {p.exclusionReason || 'Отклонен'} - - )} -
+ + + + + + + + - ))} - -
УчастникVK IDЛайкКомментПодпискаСтатус
+ + + {participants.map((p) => ( + + +
+ {p.avatarUrl ? ( + + ) : ( + p.firstName[0] + )} +
+ + {p.firstName} {p.lastName} + + + + id{p.platformUserId} + + + {p.liked ? ( + + ) : ( + + )} + + + {p.commented ? ( + {p.commentsCount || 1} + ) : ( + + )} + + + {p.subscribed ? ( + + ) : ( + + )} + + + {p.eligible ? ( + + + Допущен + + ) : ( + + + {p.exclusionReason || 'Отклонен'} + + )} + + + ))} + + + )}
+ {/* Pagination Controls */} + {totalPages > 1 && ( +
+ Страница {currentPage} из {totalPages} +
+ + +
+
+ )} +
@@ -689,76 +758,61 @@ export default function NewGiveawayWizardPage() {

Шаг 4: Настройки жеребьевки

- Слепок зафиксирован (SNAPSHOT_LOCKED). Алгоритм: HMAC_SHA256_FY_V1 + Укажите количество призовых и резервных мест

- {lockedSnapshot && ( -
-
- Snapshot ID: - {lockedSnapshot.id} -
-
- Хеш слепка участников (SHA-256): +
+
+ + Слепок зафиксирован (версия {lockedSnapshot?.version || 1}) +
+ {lockedSnapshot?.participantsSnapshotHash && ( +
+ Хеш слепка: {lockedSnapshot.participantsSnapshotHash}
-
- )} + )} +
- {/* Winners Count */} -
- +
+ setWinnersCount(Math.max(1, parseInt(e.target.value) || 1))} - className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-bold text-base 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" /> -

Призовых мест

- {/* Reserve Winners Count */} -
- +
+ setReserveWinnersCount(Math.max(0, parseInt(e.target.value) || 0))} - className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-bold text-base 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" /> -

На случай невыхода на связь

- {/* Seed configuration */} -
-
- - 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); + }); +});