/** * Grand-finale audience favorite-vote windows: * - admin opens a per-category (or overall) window with a duration * - votes are gated server-side: phase OPEN, before closesAt, category match * - one vote per voter token per window (re-vote updates), max 3 voters per IP * - no cron required: the closesAt check at vote/read time is the source of truth */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestCompetition, createTestRound, createTestProject, cleanupTestData, uid, } from '../helpers' import { liveVotingRouter } from '@/server/routers/live-voting' let program: any let round: any let session: any let startup1: any let startup2: any let concept1: any let admin: any let adminCaller: ReturnType let publicCaller: ReturnType let tokenA: string let tokenB: string async function makeVoter() { const res = await publicCaller.registerAudienceVoter({ sessionId: session.id }) return res.token as string } beforeAll(async () => { program = await createTestProgram() const competition = await createTestCompetition(program.id) round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', }) startup1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) startup2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) concept1 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' }) await prisma.round.update({ where: { id: round.id }, data: { configJson: { projectOrder: [concept1.id, startup1.id, startup2.id] } }, }) session = await prisma.liveVotingSession.create({ data: { roundId: round.id, allowAudienceVotes: true }, }) admin = await createTestUser('SUPER_ADMIN') adminCaller = createCaller(liveVotingRouter, admin) publicCaller = createCaller(liveVotingRouter, admin) // public procedures ignore the session user tokenA = await makeVoter() tokenB = await makeVoter() }) afterAll(async () => { await cleanupTestData(program.id, [admin.id]) }) describe('window lifecycle', () => { it('opens a category window with a closing time', async () => { const s = await adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'CATEGORY:STARTUP', durationMinutes: 5, }) expect(s.audiencePhase).toBe('OPEN') expect(s.audienceWindowKey).toBe('CATEGORY:STARTUP') const msLeft = new Date(s.audienceWindowClosesAt!).getTime() - Date.now() expect(msLeft).toBeGreaterThan(4 * 60_000) expect(msLeft).toBeLessThanOrEqual(5 * 60_000) }) it('rejects opening a second window while one is open', async () => { await expect( adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'CATEGORY:BUSINESS_CONCEPT', durationMinutes: 5, }) ).rejects.toThrow() }) }) describe('casting favorite votes', () => { it('accepts a vote for an in-category project and re-vote updates in place', async () => { await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenA, projectId: startup1.id, }) await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenA, projectId: startup2.id, }) const rows = await prisma.audienceFavoriteVote.findMany({ where: { sessionId: session.id, windowKey: 'CATEGORY:STARTUP' }, }) expect(rows).toHaveLength(1) expect(rows[0].projectId).toBe(startup2.id) }) it('rejects a vote for a project outside the open category', async () => { await expect( publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenB, projectId: concept1.id, }) ).rejects.toThrow(/category/i) }) it('rejects an invalid token', async () => { await expect( publicCaller.castFavoriteVote({ sessionId: session.id, token: 'bogus', projectId: startup1.id, }) ).rejects.toThrow() }) it('rejects votes after closesAt even without an explicit close (no cron)', async () => { await prisma.liveVotingSession.update({ where: { id: session.id }, data: { audienceWindowClosesAt: new Date(Date.now() - 1000) }, }) await expect( publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenB, projectId: startup1.id, }) ).rejects.toThrow() // getAudienceWindow also reports closed const win = await publicCaller.getAudienceWindow({ sessionId: session.id }) expect(win.open).toBe(false) }) it('close + re-open works and votes flow again in the new category', async () => { await adminCaller.closeAudienceWindow({ sessionId: session.id }) await expect( publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenB, projectId: startup1.id, }) ).rejects.toThrow() await adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'CATEGORY:BUSINESS_CONCEPT', durationMinutes: 5, }) await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenB, projectId: concept1.id, }) const rows = await prisma.audienceFavoriteVote.findMany({ where: { sessionId: session.id, windowKey: 'CATEGORY:BUSINESS_CONCEPT' }, }) expect(rows).toHaveLength(1) }) }) describe('overall favorite window', () => { it('requires the admin toggle before opening', async () => { await adminCaller.closeAudienceWindow({ sessionId: session.id }) await expect( adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 }) ).rejects.toThrow() await adminCaller.updateSessionConfig({ sessionId: session.id, allowOverallFavorite: true }) const s = await adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5, }) expect(s.audienceWindowKey).toBe('OVERALL') }) it('accepts any ordered project in OVERALL mode', async () => { await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenA, projectId: concept1.id, }) await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenB, projectId: startup1.id, }) const rows = await prisma.audienceFavoriteVote.findMany({ where: { sessionId: session.id, windowKey: 'OVERALL' }, }) expect(rows).toHaveLength(2) }) }) describe('IP cap', () => { it('rejects a 4th distinct voter from the same IP in one window', async () => { // tokenA + tokenB already voted in OVERALL from ctx ip 127.0.0.1, but their // stored ipAddress comes from ctx — normalize the rows to be explicit: await prisma.audienceFavoriteVote.updateMany({ where: { sessionId: session.id, windowKey: 'OVERALL' }, data: { ipAddress: '127.0.0.1' }, }) const tokenC = await makeVoter() await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenC, projectId: startup2.id, }) const tokenD = await makeVoter() await expect( publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenD, projectId: startup1.id, }) ).rejects.toThrow(/limit/i) }) it('an existing voter on a capped IP can still change their vote', async () => { await publicCaller.castFavoriteVote({ sessionId: session.id, token: tokenA, projectId: startup2.id, }) const row = await prisma.audienceFavoriteVote.findFirst({ where: { sessionId: session.id, windowKey: 'OVERALL', audienceVoter: { token: tokenA } }, }) expect(row?.projectId).toBe(startup2.id) }) }) describe('reads', () => { it('getAudienceWindow lists eligible projects in order and the caller’s vote', async () => { const win = await publicCaller.getAudienceWindow({ sessionId: session.id, token: tokenA }) expect(win.open).toBe(true) expect(win.windowKey).toBe('OVERALL') expect(win.projects.map((p: any) => p.id)).toEqual([concept1.id, startup1.id, startup2.id]) expect(win.myVoteProjectId).toBe(startup2.id) }) it('getFavoriteTallies aggregates per window per project', async () => { const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id }) const overall = tallies.windows.find((w: any) => w.windowKey === 'OVERALL') expect(overall.totalVotes).toBe(3) const startup2Count = overall.projects.find((p: any) => p.projectId === startup2.id)?.count expect(startup2Count).toBe(2) }) it('getAudienceContextByRound resolves the session publicly', async () => { const ctx = await publicCaller.getAudienceContextByRound({ roundId: round.id }) expect(ctx?.sessionId).toBe(session.id) expect(ctx?.allowAudienceVotes).toBe(true) }) it('non-admins cannot open windows or read tallies', async () => { const juror = await createTestUser('JURY_MEMBER') const jurorCaller = createCaller(liveVotingRouter, juror) await expect( jurorCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 }) ).rejects.toThrow() await expect(jurorCaller.getFavoriteTallies({ sessionId: session.id })).rejects.toThrow() await prisma.user.delete({ where: { id: juror.id } }) }) })