202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { executeDeterministicDraw, verifyDrawResult } from '../src/core/randomizer/deterministic';
|
|
import { computeParticipantsSnapshotHash, generateRandomSeed } from '../src/core/randomizer/hasher';
|
|
import { FilteredParticipant } from '../src/core/types/participant';
|
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
|
|
|
function createMockEligibleParticipants(count: number): FilteredParticipant[] {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
platformUserId: `${1000 + i}`,
|
|
firstName: `User${i + 1}`,
|
|
lastName: `Surname${i + 1}`,
|
|
username: `user_${i + 1}`,
|
|
avatarUrl: `https://example.com/avatar/${i + 1}.jpg`,
|
|
source: 'LIKES',
|
|
liked: true,
|
|
commented: true,
|
|
commentsCount: 1,
|
|
reposted: true,
|
|
subscribed: true,
|
|
eligible: true,
|
|
exclusionReason: null,
|
|
}));
|
|
}
|
|
|
|
describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|
it('should generate identical winners given the same participants snapshot and seed', () => {
|
|
const participants = createMockEligibleParticipants(50);
|
|
const seed = 'test-secret-seed-2026';
|
|
|
|
const draw1 = executeDeterministicDraw({
|
|
giveawayId: 'gw-1',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 50,
|
|
winnersCount: 3,
|
|
reserveWinnersCount: 2,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
const draw2 = executeDeterministicDraw({
|
|
giveawayId: 'gw-1',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 50,
|
|
winnersCount: 3,
|
|
reserveWinnersCount: 2,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
|
|
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
|
|
draw2.winners.map(w => w.participant.platformUserId)
|
|
);
|
|
expect(draw1.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
|
|
draw2.reserveWinners.map(w => w.participant.platformUserId)
|
|
);
|
|
expect(draw1.verificationSignature).toBe(draw2.verificationSignature);
|
|
});
|
|
|
|
it('should produce different winners when seed changes', () => {
|
|
const participants = createMockEligibleParticipants(100);
|
|
const seedA = 'seed-alpha-123';
|
|
const seedB = 'seed-beta-456';
|
|
|
|
const drawA = executeDeterministicDraw({
|
|
giveawayId: 'gw-a',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 100,
|
|
winnersCount: 5,
|
|
reserveWinnersCount: 2,
|
|
seed: seedA,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
const drawB = executeDeterministicDraw({
|
|
giveawayId: 'gw-b',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 100,
|
|
winnersCount: 5,
|
|
reserveWinnersCount: 2,
|
|
seed: seedB,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
const winnersA = drawA.winners.map(w => w.participant.platformUserId);
|
|
const winnersB = drawB.winners.map(w => w.participant.platformUserId);
|
|
|
|
expect(winnersA).not.toEqual(winnersB);
|
|
});
|
|
|
|
it('should guarantee no duplicates between winners and reserve winners', () => {
|
|
const participants = createMockEligibleParticipants(30);
|
|
const seed = generateRandomSeed();
|
|
|
|
const draw = executeDeterministicDraw({
|
|
giveawayId: 'gw-uniq',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 30,
|
|
winnersCount: 5,
|
|
reserveWinnersCount: 5,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
const allChosenIds = [
|
|
...draw.winners.map(w => w.participant.platformUserId),
|
|
...draw.reserveWinners.map(w => w.participant.platformUserId),
|
|
];
|
|
|
|
const uniqueIds = new Set(allChosenIds);
|
|
expect(uniqueIds.size).toBe(10);
|
|
});
|
|
|
|
it('should handle cases where pool size is smaller than requested winners', () => {
|
|
const participants = createMockEligibleParticipants(2);
|
|
const seed = 'small-pool-seed';
|
|
|
|
const draw = executeDeterministicDraw({
|
|
giveawayId: 'gw-small',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 2,
|
|
winnersCount: 5,
|
|
reserveWinnersCount: 3,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
expect(draw.winners.length).toBe(2);
|
|
expect(draw.reserveWinners.length).toBe(0);
|
|
});
|
|
|
|
it('should throw when attempting draw with 0 eligible participants', () => {
|
|
expect(() => {
|
|
executeDeterministicDraw({
|
|
giveawayId: 'gw-empty',
|
|
eligibleParticipants: [],
|
|
totalLoadedCount: 0,
|
|
winnersCount: 1,
|
|
reserveWinnersCount: 0,
|
|
seed: 'empty-seed',
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
}).toThrow(/Cannot conduct draw with 0 eligible participants/);
|
|
});
|
|
|
|
it('should be independent of input participant ordering (canonical sorting)', () => {
|
|
const p1 = createMockEligibleParticipants(20);
|
|
const p2 = [...p1].reverse(); // reversed order
|
|
|
|
const seed = 'sort-order-invariant-seed';
|
|
|
|
const draw1 = executeDeterministicDraw({
|
|
giveawayId: 'gw-sort',
|
|
eligibleParticipants: p1,
|
|
totalLoadedCount: 20,
|
|
winnersCount: 3,
|
|
reserveWinnersCount: 1,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
const draw2 = executeDeterministicDraw({
|
|
giveawayId: 'gw-sort',
|
|
eligibleParticipants: p2,
|
|
totalLoadedCount: 20,
|
|
winnersCount: 3,
|
|
reserveWinnersCount: 1,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
|
|
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
|
|
draw2.winners.map(w => w.participant.platformUserId)
|
|
);
|
|
});
|
|
|
|
it('should allow third-party verification through verifyDrawResult', () => {
|
|
const participants = createMockEligibleParticipants(25);
|
|
const seed = 'audit-verification-seed';
|
|
|
|
const originalDraw = executeDeterministicDraw({
|
|
giveawayId: 'gw-audit',
|
|
eligibleParticipants: participants,
|
|
totalLoadedCount: 25,
|
|
winnersCount: 2,
|
|
reserveWinnersCount: 2,
|
|
seed,
|
|
filterRules: DEFAULT_FILTER_RULES,
|
|
});
|
|
|
|
const verification = verifyDrawResult(participants, seed, 2, 2);
|
|
|
|
expect(verification.snapshotHash).toBe(originalDraw.participantsSnapshotHash);
|
|
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual(
|
|
originalDraw.winners.map(w => w.participant.platformUserId)
|
|
);
|
|
expect(verification.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
|
|
originalDraw.reserveWinners.map(w => w.participant.platformUserId)
|
|
);
|
|
});
|
|
});
|