272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import { createHash } from 'crypto';
|
|
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
|
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
|
import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/route';
|
|
import { GET as verifyGet } from '../src/app/api/giveaways/[id]/verify/route';
|
|
import { GiveawayStore } from '../src/lib/giveaway-store';
|
|
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
|
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
|
import { FilteredParticipant } from '../src/core/types/participant';
|
|
import { ConflictError } from '../src/core/errors/http-errors';
|
|
import { computeSeedCommitment } from '../src/core/randomizer/hasher';
|
|
|
|
describe('Phase 2.4.1 — Atomic Snapshot + Seed Commitment Binding', () => {
|
|
const organizerUser = { id: 'usr_atomic_snap_organizer', vkUserId: '888222' };
|
|
let sessionCookie: string;
|
|
|
|
const testParticipants: FilteredParticipant[] = Array.from({ length: 50 }, (_, i) => ({
|
|
platformUserId: `${2000 + i}`,
|
|
firstName: `User${i}`,
|
|
lastName: `Atomic${i}`,
|
|
source: 'LIKES',
|
|
liked: true,
|
|
commented: false,
|
|
commentsCount: 0,
|
|
reposted: false,
|
|
subscribed: true,
|
|
eligible: true,
|
|
exclusionReason: null,
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
|
defaultSessionStore.clear();
|
|
const sessionId = await defaultSessionStore.createSession(organizerUser);
|
|
sessionCookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
|
|
});
|
|
|
|
async function createReadyGiveaway() {
|
|
const gw = await GiveawayStore.create({
|
|
sourceUrl: 'https://vk.com/wall-33445566_789',
|
|
post: {
|
|
platform: 'VK',
|
|
ownerId: '-33445566',
|
|
postId: '789',
|
|
sourceUrl: 'https://vk.com/wall-33445566_789',
|
|
title: 'Atomic Snapshot Test Post',
|
|
text: 'Test description',
|
|
likesCount: 50,
|
|
commentsCount: 0,
|
|
repostsCount: 0,
|
|
},
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
organizerId: organizerUser.id,
|
|
});
|
|
|
|
await GiveawayStore.updateParticipants(gw.id, testParticipants);
|
|
return gw;
|
|
}
|
|
|
|
// ─── Test 1: Memory Repository Single Lock Invariant & Concurrency ───────────
|
|
it('Memory repository: 20 concurrent createAndLockSnapshot calls yield exactly 1 success and 19 ConflictErrors', 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: 'Parity Test',
|
|
text: 'Test description',
|
|
likesCount: 50,
|
|
commentsCount: 0,
|
|
repostsCount: 0,
|
|
},
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
organizerId: 'org_atomic_mem',
|
|
});
|
|
|
|
await repo.saveParticipants(gw.id, testParticipants);
|
|
|
|
const attempts = Array.from({ length: 20 }, async (_, index) => {
|
|
try {
|
|
const res = await repo.createAndLockSnapshot(gw.id, testParticipants, {
|
|
...DEFAULT_FILTER_RULES,
|
|
requireLike: index % 2 === 0,
|
|
});
|
|
return { success: true, res };
|
|
} catch (err: any) {
|
|
return { success: false, error: err };
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(attempts);
|
|
const successes = results.filter(r => r.success);
|
|
const conflicts = results.filter(r => !r.success);
|
|
|
|
expect(successes).toHaveLength(1);
|
|
expect(conflicts).toHaveLength(19);
|
|
|
|
// The winning result has both snapshot and seedCommitment
|
|
const winning = (successes[0] as any).res;
|
|
expect(winning.snapshot).toBeDefined();
|
|
expect(winning.seedCommitment).toBeDefined();
|
|
expect(winning.seedCommitment).toHaveLength(64);
|
|
|
|
// Verify DB state
|
|
const locked = await repo.getGiveawayById(gw.id);
|
|
expect(locked?.status).toBe('SNAPSHOT_LOCKED');
|
|
expect(locked?.snapshots).toHaveLength(1);
|
|
expect(locked?.seed).toBeDefined();
|
|
expect(computeSeedCommitment(locked!.seed!)).toBe(winning.seedCommitment);
|
|
|
|
// All failed attempts threw ConflictError
|
|
conflicts.forEach(c => {
|
|
expect(c.error).toBeInstanceOf(ConflictError);
|
|
});
|
|
});
|
|
|
|
// ─── Test 2: API Concurrency with different Idempotency keys ─────────────────
|
|
it('API route: concurrent snapshot lock requests with different Idempotency-Keys produce exactly 1 200 and remaining 409s', async () => {
|
|
const gw = await createReadyGiveaway();
|
|
|
|
const requests = Array.from({ length: 5 }, (_, i) => {
|
|
const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: sessionCookie,
|
|
'Idempotency-Key': `key-diff-${i}-${Date.now()}`,
|
|
},
|
|
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
|
});
|
|
return snapshotPost(req, { params: { id: gw.id } });
|
|
});
|
|
|
|
const responses = await Promise.all(requests);
|
|
const statusCodes = responses.map(r => r.status);
|
|
|
|
const count200 = statusCodes.filter(s => s === 200).length;
|
|
const count409 = statusCodes.filter(s => s === 409).length;
|
|
|
|
expect(count200).toBe(1);
|
|
expect(count409).toBe(4);
|
|
|
|
const successRes = responses.find(r => r.status === 200)!;
|
|
const body = await successRes.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.status).toBe('SNAPSHOT_LOCKED');
|
|
expect(body.snapshot).toBeDefined();
|
|
expect(body.seedCommitment).toBeDefined();
|
|
|
|
// Verify persisted seed matches returned commitment directly
|
|
const stored = await GiveawayStore.getById(gw.id);
|
|
expect(stored?.status).toBe('SNAPSHOT_LOCKED');
|
|
expect(computeSeedCommitment(stored!.seed!)).toBe(body.seedCommitment);
|
|
});
|
|
|
|
// ─── Test 3: Idempotency Replay with same key vs new key ─────────────────────
|
|
it('idempotency: replaying same key returns cached commitment; new key after lock returns 409', async () => {
|
|
const gw = await createReadyGiveaway();
|
|
const idempotencyKey = `idem-key-stable-${Date.now()}`;
|
|
|
|
// 1. Initial lock
|
|
const req1 = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: sessionCookie,
|
|
'Idempotency-Key': idempotencyKey,
|
|
},
|
|
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
|
});
|
|
|
|
const res1 = await snapshotPost(req1, { params: { id: gw.id } });
|
|
expect(res1.status).toBe(200);
|
|
const data1 = await res1.json();
|
|
const initialCommitment = data1.seedCommitment;
|
|
expect(initialCommitment).toMatch(/^[a-f0-9]{64}$/);
|
|
|
|
// 2. Replay with identical key -> returns cached 200 with identical snapshot & commitment
|
|
const replayReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: sessionCookie,
|
|
'Idempotency-Key': idempotencyKey,
|
|
},
|
|
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
|
});
|
|
|
|
const replayRes = await snapshotPost(replayReq, { params: { id: gw.id } });
|
|
expect(replayRes.status).toBe(200);
|
|
const replayData = await replayRes.json();
|
|
expect(replayData.seedCommitment).toBe(initialCommitment);
|
|
expect(replayData.snapshot.id).toBe(data1.snapshot.id);
|
|
|
|
// 3. New request with different key -> 409 Conflict
|
|
const newKeyReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: sessionCookie,
|
|
'Idempotency-Key': `new-key-${Date.now()}`,
|
|
},
|
|
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
|
});
|
|
|
|
const newKeyRes = await snapshotPost(newKeyReq, { params: { id: gw.id } });
|
|
expect(newKeyRes.status).toBe(409);
|
|
});
|
|
|
|
// ─── Test 4: Commitment Stability and Provable Draw Verification ────────────
|
|
it('commitment stability: commitment is invariant across reads and equals sha256(seedUsed) after draw', async () => {
|
|
const gw = await createReadyGiveaway();
|
|
|
|
// 1. Lock snapshot
|
|
const snapReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: sessionCookie,
|
|
},
|
|
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
|
});
|
|
|
|
const snapRes = await snapshotPost(snapReq, { params: { id: gw.id } });
|
|
expect(snapRes.status).toBe(200);
|
|
const snapData = await snapRes.json();
|
|
const lockedCommitment = snapData.seedCommitment;
|
|
|
|
// 2. Repeated GET before draw
|
|
for (let i = 0; i < 3; i++) {
|
|
const getReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}`, {
|
|
method: 'GET',
|
|
headers: { Cookie: sessionCookie },
|
|
});
|
|
const getRes = await giveawayDetailGet(getReq, { params: { id: gw.id } });
|
|
const getData = await getRes.json();
|
|
expect(getData.giveaway.seed).toBeNull(); // Masked before DRAWN
|
|
expect(getData.giveaway.seedCommitment).toBe(lockedCommitment);
|
|
}
|
|
|
|
// 3. Execute draw
|
|
const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/draw`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: sessionCookie,
|
|
},
|
|
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
|
});
|
|
|
|
const drawRes = await drawPost(drawReq, { params: { id: gw.id } });
|
|
expect(drawRes.status).toBe(200);
|
|
const drawData = await drawRes.json();
|
|
|
|
const seedUsed = drawData.drawResult.seedUsed;
|
|
expect(createHash('sha256').update(seedUsed).digest('hex')).toBe(lockedCommitment);
|
|
|
|
// 4. Verify public endpoint
|
|
const verifyReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/verify`, {
|
|
method: 'GET',
|
|
});
|
|
const verifyRes = await verifyGet(verifyReq, { params: { id: gw.id } });
|
|
const verifyData = await verifyRes.json();
|
|
expect(verifyData.verified).toBe(true);
|
|
});
|
|
});
|