feat(finale): reveal controller + public ceremony-state endpoint (no-leak guaranteed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
160
tests/unit/reveal.test.ts
Normal file
160
tests/unit/reveal.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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<typeof createCaller>
|
||||
let publicCaller: ReturnType<typeof createCaller>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user