278 lines
9.3 KiB
TypeScript
278 lines
9.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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 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 } })
|
|||
|
|
})
|
|||
|
|
})
|