feat(finale): audience favorite-vote windows with category gating + IP cap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:09:02 +02:00
parent 6eccfc694e
commit 6d2fa3369f
2 changed files with 613 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
/**
* 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<typeof createCaller>
let publicCaller: ReturnType<typeof createCaller>
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 callers 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 } })
})
})