randomayzer/tests/integration/prisma-repository.test.ts

359 lines
14 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { prisma } from '../../src/lib/prisma';
import { PrismaGiveawayRepository } from '../../src/lib/repository/prisma-repository';
import { PrismaUserRepository } from '../../src/lib/repository/user-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';
import { computeSeedCommitment } from '../../src/core/randomizer/hasher';
import { DrawExecutionResult } from '../../src/core/types/audit';
// Ensure DATABASE_URL is explicitly configured for integration runs
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
'DATABASE_URL environment variable is required to run Prisma integration tests. ' +
'Please set DATABASE_URL to a running PostgreSQL database (e.g. postgresql://postgres:postgres@localhost:5432/randomayzer).'
);
}
describe('Task 08: Prisma Integration Test Harness (PostgreSQL)', () => {
const giveawayRepo = new PrismaGiveawayRepository();
const userRepo = new PrismaUserRepository();
let testOrganizer: { id: string; vkUserId: string };
beforeAll(async () => {
try {
await prisma.$connect();
} catch (err: any) {
throw new Error(
`Failed to connect to PostgreSQL at "${DATABASE_URL}": ${err.message}. Ensure database is running and migrations are applied.`
);
}
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
// Clean all tables in reverse dependency order
await prisma.auditRecord.deleteMany();
await prisma.drawResult.deleteMany();
await prisma.participantSnapshot.deleteMany();
await prisma.participant.deleteMany();
await prisma.giveaway.deleteMany();
await prisma.userCredential.deleteMany();
await prisma.session.deleteMany();
await prisma.oAuthTransaction.deleteMany();
await prisma.user.deleteMany();
// Create a base test organizer user
testOrganizer = await userRepo.upsertUserWithTokens({
vkUserId: '888777666',
firstName: 'Integration',
lastName: 'Organizer',
encryptedAccessToken: 'enc_access_token_123',
encryptedRefreshToken: 'enc_refresh_token_123',
expiresIn: 86400,
});
});
const sampleParticipants: FilteredParticipant[] = Array.from({ length: 15 }, (_, i) => ({
platformUserId: `${1000 + i}`,
firstName: `User${i}`,
lastName: `Test${i}`,
source: 'LIKES' as const,
liked: true,
commented: i % 2 === 0,
commentsCount: i % 2 === 0 ? 1 : 0,
reposted: false,
subscribed: true,
eligible: i < 10,
exclusionReason: i >= 10 ? 'NOT_SUBSCRIBED' : null,
}));
const eligibleOnly = sampleParticipants.filter(p => p.eligible);
async function createReadyGiveaway() {
const gw = await giveawayRepo.createGiveaway({
sourceUrl: 'https://vk.com/wall-123_456',
post: {
platform: 'VK',
ownerId: '-123',
postId: '456',
sourceUrl: 'https://vk.com/wall-123_456',
title: 'Prisma Integration Giveaway',
text: 'Integration test post content',
likesCount: 15,
commentsCount: 8,
repostsCount: 0,
},
filterRules: DEFAULT_FILTER_RULES,
organizerId: testOrganizer.id,
});
await giveawayRepo.saveParticipants(gw.id, sampleParticipants);
return gw;
}
// ─── 1. createAndLockSnapshot: Atomic seed generation & status lock ───────────
it('createAndLockSnapshot: atomically locks snapshot, generates seed, computes commitment, and transitions to SNAPSHOT_LOCKED', async () => {
const gw = await createReadyGiveaway();
const locked = await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
expect(locked.snapshot).toBeDefined();
expect(locked.snapshot.version).toBe(1);
expect(locked.snapshot.participantCount).toBe(10); // 10 eligible participants
expect(locked.snapshot.participantsSnapshotHash).toMatch(/^[a-f0-9]{64}$/);
expect(locked.snapshot.conditionsHash).toMatch(/^[a-f0-9]{64}$/);
// Verify DB state
const fromDb = await giveawayRepo.getGiveawayById(gw.id);
expect(fromDb?.status).toBe('SNAPSHOT_LOCKED');
expect(fromDb?.seed).toMatch(/^[a-f0-9]{64}$/);
expect(locked.seedCommitment).toBe(computeSeedCommitment(fromDb!.seed!));
});
// ─── 2. Single Lock Invariant ────────────────────────────────────────────────
it('createAndLockSnapshot: repeated lock on SNAPSHOT_LOCKED throws ConflictError', async () => {
const gw = await createReadyGiveaway();
await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
// Second lock attempt must fail with ConflictError
await expect(
giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES)
).rejects.toThrow(ConflictError);
});
// ─── 3. Concurrent lock attempts: Exactly 1 succeeds on PostgreSQL ────────────
it('createAndLockSnapshot: 10 concurrent requests result in exactly 1 successful lock', async () => {
const gw = await createReadyGiveaway();
const results = await Promise.allSettled(
Array.from({ length: 10 }, () =>
giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES)
)
);
const fulfilled = results.filter(r => r.status === 'fulfilled');
const rejected = results.filter(r => r.status === 'rejected');
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(9);
for (const rej of rejected) {
if (rej.status === 'rejected') {
expect(rej.reason).toBeInstanceOf(ConflictError);
}
}
const finalGw = await giveawayRepo.getGiveawayById(gw.id);
expect(finalGw?.status).toBe('SNAPSHOT_LOCKED');
});
// ─── 4. saveDrawResultAndAudit: Atomic transition to DRAWN ────────────────────
it('saveDrawResultAndAudit: transitions SNAPSHOT_LOCKED to DRAWN and creates DrawResult + AuditRecord', async () => {
const gw = await createReadyGiveaway();
const locked = await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
const lockedGw = (await giveawayRepo.getGiveawayById(gw.id))!;
const drawResult: DrawExecutionResult = {
drawId: `draw_${Date.now()}`,
giveawayId: gw.id,
snapshotId: locked.snapshot.id,
winners: [
{
position: 1,
participant: locked.snapshot.eligibleParticipants[0],
isReserve: false,
selectionIndex: 0,
proofHash: 'proof_win_1',
},
],
reserveWinners: [],
winnerIds: [locked.snapshot.eligibleParticipants[0].platformUserId],
reserveWinnerIds: [],
totalEligibleCount: locked.snapshot.participantCount,
totalLoadedCount: 15,
seedUsed: lockedGw.seed!,
algorithmVersion: 'HMAC_SHA256_FY_V1',
deterministicProofHash: 'a'.repeat(64),
auditEventHash: 'b'.repeat(64),
drawnAt: new Date().toISOString(),
participantsSnapshotHash: locked.snapshot.participantsSnapshotHash,
conditionsHash: locked.snapshot.conditionsHash,
};
const drawnGw = await giveawayRepo.saveDrawResultAndAudit(gw.id, locked.snapshot.id, drawResult);
expect(drawnGw.status).toBe('DRAWN');
expect(drawnGw.drawnAt).toBeDefined();
expect(drawnGw.drawResult).toBeDefined();
expect(drawnGw.drawResult?.drawId).toBe(drawResult.drawId);
// Verify AuditRecord in DB
const auditInDb = await prisma.auditRecord.findFirst({ where: { giveawayId: gw.id } });
expect(auditInDb).toBeDefined();
expect(auditInDb?.seed).toBe(lockedGw.seed);
expect(auditInDb?.deterministicProofHash).toBe('a'.repeat(64));
});
// ─── 5. saveDrawResultAndAudit: Repeat draw prevention (P2002) ────────────────
it('saveDrawResultAndAudit: second draw attempt on DRAWN giveaway throws ConflictError', async () => {
const gw = await createReadyGiveaway();
const locked = await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
const lockedGw = (await giveawayRepo.getGiveawayById(gw.id))!;
const drawResult: DrawExecutionResult = {
drawId: `draw_${Date.now()}`,
giveawayId: gw.id,
snapshotId: locked.snapshot.id,
winners: [
{
position: 1,
participant: locked.snapshot.eligibleParticipants[0],
isReserve: false,
selectionIndex: 0,
proofHash: 'proof_win_1',
},
],
reserveWinners: [],
winnerIds: [locked.snapshot.eligibleParticipants[0].platformUserId],
reserveWinnerIds: [],
totalEligibleCount: locked.snapshot.participantCount,
totalLoadedCount: 15,
seedUsed: lockedGw.seed!,
algorithmVersion: 'HMAC_SHA256_FY_V1',
deterministicProofHash: 'a'.repeat(64),
auditEventHash: 'b'.repeat(64),
drawnAt: new Date().toISOString(),
participantsSnapshotHash: locked.snapshot.participantsSnapshotHash,
conditionsHash: locked.snapshot.conditionsHash,
};
await giveawayRepo.saveDrawResultAndAudit(gw.id, locked.snapshot.id, drawResult);
// Second draw must fail
await expect(
giveawayRepo.saveDrawResultAndAudit(gw.id, locked.snapshot.id, {
...drawResult,
drawId: `draw_repeat_${Date.now()}`,
})
).rejects.toThrow(ConflictError);
});
// ─── 6. unlockSnapshot: SNAPSHOT_LOCKED -> READY and resets seed ──────────────
it('unlockSnapshot: transitions SNAPSHOT_LOCKED to READY and resets seed to null', async () => {
const gw = await createReadyGiveaway();
await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
const unlocked = await giveawayRepo.unlockSnapshot(gw.id);
expect(unlocked.status).toBe('READY');
expect(unlocked.seed).toBeNull();
const fromDb = await giveawayRepo.getGiveawayById(gw.id);
expect(fromDb?.status).toBe('READY');
expect(fromDb?.seed).toBeNull();
});
// ─── 7. Snapshot versioning across unlock & relock ────────────────────────────
it('snapshot versioning: re-locking creates version 2 and preserves version 1 in DB', async () => {
const gw = await createReadyGiveaway();
const lock1 = await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
expect(lock1.snapshot.version).toBe(1);
const seed1 = (await giveawayRepo.getGiveawayById(gw.id))?.seed;
await giveawayRepo.unlockSnapshot(gw.id);
const lock2 = await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
expect(lock2.snapshot.version).toBe(2);
const seed2 = (await giveawayRepo.getGiveawayById(gw.id))?.seed;
expect(seed2).not.toBe(seed1);
// Verify all snapshots in DB
const allSnaps = await prisma.participantSnapshot.findMany({
where: { giveawayId: gw.id },
orderBy: { version: 'asc' },
});
expect(allSnaps).toHaveLength(2);
expect(allSnaps[0].version).toBe(1);
expect(allSnaps[1].version).toBe(2);
});
// ─── 8. saveParticipants: Requires READY status ───────────────────────────────
it('saveParticipants: throws ConflictError when giveaway is in SNAPSHOT_LOCKED status', async () => {
const gw = await createReadyGiveaway();
await giveawayRepo.createAndLockSnapshot(gw.id, eligibleOnly, DEFAULT_FILTER_RULES);
await expect(
giveawayRepo.saveParticipants(gw.id, sampleParticipants)
).rejects.toThrow(ConflictError);
});
// ─── 9. Ownership: onDelete: Restrict on User -> Giveaway ────────────────────
it('ownership constraint: deleting a user with existing giveaways throws foreign key violation', async () => {
await createReadyGiveaway();
// Attempting to delete the user must fail with Prisma foreign key violation
await expect(
prisma.user.delete({ where: { id: testOrganizer.id } })
).rejects.toThrow();
});
// ─── 10. getParticipantsPaginated: Pagination and counts ─────────────────────
it('getParticipantsPaginated: correctly calculates counts and returns paginated slice', async () => {
const gw = await createReadyGiveaway();
const page1 = await giveawayRepo.getParticipantsPaginated(gw.id, 1, 5, 'all');
expect(page1.totalCount).toBe(15);
expect(page1.eligibleCount).toBe(10);
expect(page1.excludedCount).toBe(5);
expect(page1.totalPages).toBe(3);
expect(page1.participants).toHaveLength(5);
const eligiblePage = await giveawayRepo.getParticipantsPaginated(gw.id, 1, 20, 'eligible');
expect(eligiblePage.participants).toHaveLength(10);
expect(eligiblePage.participants.every(p => p.eligible)).toBe(true);
const excludedPage = await giveawayRepo.getParticipantsPaginated(gw.id, 1, 20, 'excluded');
expect(excludedPage.participants).toHaveLength(5);
expect(excludedPage.participants.every(p => !p.eligible)).toBe(true);
});
// ─── 11. PrismaUserRepository: updateCredentialConditionally CAS ─────────────
it('PrismaUserRepository: updateCredentialConditionally succeeds with matching updatedAt and fails on stale', async () => {
const credBefore = await userRepo.getUserCredentials(testOrganizer.id);
expect(credBefore).toBeDefined();
// 1. Valid update with expected updatedAt
const success = await userRepo.updateCredentialConditionally(
testOrganizer.id,
{
encryptedAccessToken: 'fresh_enc_access_token',
encryptedRefreshToken: 'fresh_enc_refresh_token',
expiresAt: new Date(Date.now() + 3600000),
},
credBefore!.updatedAt
);
expect(success).toBe(true);
// 2. Stale update with old updatedAt
const staleAttempt = await userRepo.updateCredentialConditionally(
testOrganizer.id,
{
encryptedAccessToken: 'stale_token',
encryptedRefreshToken: 'stale_refresh',
expiresAt: null,
},
credBefore!.updatedAt // Old timestamp
);
expect(staleAttempt).toBe(false);
});
});