/** * Results reveal controller: admin composes steps privately (DRAFT), arms the * big screen (ARMED), then steps through one reveal at a time (REVEALING → * DONE). The public ceremony-state endpoint must NEVER leak un-revealed steps. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestCompetition, createTestRound, createTestProject, cleanupTestData, } from '../helpers' import { liveVotingRouter } from '@/server/routers/live-voting' import { liveRouter } from '@/server/routers/live' let program: any let round: any let session: any let project: any let admin: any let adminCaller: ReturnType let publicCaller: ReturnType const steps = [ { kind: 'category-intro' as const, category: 'STARTUP' as const, title: 'Startups' }, { kind: 'place' as const, category: 'STARTUP' as const, place: 3, title: 'Team Gamma', subtitle: '3rd place — Startups' }, { kind: 'place' as const, category: 'STARTUP' as const, place: 2, title: 'Team Beta', subtitle: '2nd place — Startups' }, { kind: 'place' as const, category: 'STARTUP' as const, place: 1, title: 'Team Alpha', subtitle: 'Winner — Startups' }, { kind: 'thanks' as const, title: 'Thank you' }, ] beforeAll(async () => { program = await createTestProgram() const competition = await createTestCompetition(program.id) round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }) project = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) await prisma.round.update({ where: { id: round.id }, data: { configJson: { projectOrder: [project.id] } }, }) session = await prisma.liveVotingSession.create({ data: { roundId: round.id } }) admin = await createTestUser('SUPER_ADMIN') adminCaller = createCaller(liveVotingRouter, admin) publicCaller = createCaller(liveVotingRouter, admin) // a cursor so getCeremonyState has phase data const liveCaller = createCaller(liveRouter, admin) await liveCaller.start({ roundId: round.id, projectOrder: [project.id] }) }) afterAll(async () => { await cleanupTestData(program.id, [admin.id]) }) describe('reveal lifecycle', () => { it('saveReveal upserts a DRAFT', async () => { const r1 = await adminCaller.saveReveal({ sessionId: session.id, steps: steps.slice(0, 2) }) expect(r1.status).toBe('DRAFT') const r2 = await adminCaller.saveReveal({ sessionId: session.id, steps }) expect((r2.stepsJson as any[]).length).toBe(5) expect(r2.currentStepIndex).toBe(-1) }) it('armReveal requires steps and moves DRAFT → ARMED', async () => { const r = await adminCaller.armReveal({ sessionId: session.id }) expect(r.status).toBe('ARMED') }) it('revealNext steps through and lands on DONE at the last step', async () => { let r = await adminCaller.revealNext({ sessionId: session.id }) expect(r.status).toBe('REVEALING') expect(r.currentStepIndex).toBe(0) r = await adminCaller.revealNext({ sessionId: session.id }) expect(r.currentStepIndex).toBe(1) r = await adminCaller.revealNext({ sessionId: session.id }) r = await adminCaller.revealNext({ sessionId: session.id }) r = await adminCaller.revealNext({ sessionId: session.id }) expect(r.currentStepIndex).toBe(4) expect(r.status).toBe('DONE') // advancing past the end stays clamped at the final step r = await adminCaller.revealNext({ sessionId: session.id }) expect(r.currentStepIndex).toBe(4) expect(r.status).toBe('DONE') }) it('public ceremony state only exposes revealed steps', async () => { // wind back mid-reveal await prisma.revealState.update({ where: { sessionId: session.id }, data: { status: 'REVEALING', currentStepIndex: 1 }, }) const state = await publicCaller.getCeremonyState({ roundId: round.id }) expect(state.reveal?.status).toBe('REVEALING') expect(state.reveal?.steps).toHaveLength(2) // steps 0 and 1 only expect(state.reveal?.steps?.some((s: any) => s.title === 'Team Alpha')).toBe(false) }) it('public ceremony state shows no steps when ARMED and no reveal when DRAFT', async () => { await prisma.revealState.update({ where: { sessionId: session.id }, data: { status: 'ARMED', currentStepIndex: -1 }, }) let state = await publicCaller.getCeremonyState({ roundId: round.id }) expect(state.reveal?.status).toBe('ARMED') expect(state.reveal?.steps).toHaveLength(0) await adminCaller.resetReveal({ sessionId: session.id }) state = await publicCaller.getCeremonyState({ roundId: round.id }) expect(state.reveal).toBeNull() }) it('reveal mutations are admin-only', async () => { const juror = await createTestUser('JURY_MEMBER') const jurorCaller = createCaller(liveVotingRouter, juror) await expect(jurorCaller.armReveal({ sessionId: session.id })).rejects.toThrow() await expect(jurorCaller.revealNext({ sessionId: session.id })).rejects.toThrow() await prisma.user.delete({ where: { id: juror.id } }) }) }) describe('ceremony state composition', () => { it('exposes phase, active project, audience window and never any scores', async () => { const state = await publicCaller.getCeremonyState({ roundId: round.id }) expect(state.phase?.projectPhase).toBe('ON_DECK') expect(state.activeProject?.title).toBe(project.title) expect(state.audience.open).toBe(false) expect(JSON.stringify(state)).not.toMatch(/score/i) }) it('includes the live vote count while a window is open', async () => { await adminCaller.updateSessionConfig({ sessionId: session.id, allowAudienceVotes: true }) await adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'CATEGORY:STARTUP', durationMinutes: 5, }) const reg = await publicCaller.registerAudienceVoter({ sessionId: session.id }) await publicCaller.castFavoriteVote({ sessionId: session.id, token: reg.token, projectId: project.id, }) const state = await publicCaller.getCeremonyState({ roundId: round.id }) expect(state.audience.open).toBe(true) expect(state.audience.windowKey).toBe('CATEGORY:STARTUP') expect(state.audience.voteCount).toBe(1) }) it('reports the override slide', async () => { const liveCaller = createCaller(liveRouter, admin) await liveCaller.setOverrideSlide({ roundId: round.id, slide: 'break' }) const state = await publicCaller.getCeremonyState({ roundId: round.id }) expect(state.overrideSlide).toBe('break') }) })