Files
MOPC-Portal/tests/unit/audience-window.test.ts
2026-06-10 18:09:02 +02:00

278 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } })
})
})