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:
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
interface LiveVotingCriterion {
|
||||
@@ -11,6 +12,49 @@ interface LiveVotingCriterion {
|
||||
weight: number
|
||||
}
|
||||
|
||||
// ─── Grand-finale audience favorite-vote windows ─────────────────────────────
|
||||
|
||||
const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])
|
||||
|
||||
const MAX_FAVORITE_VOTERS_PER_IP = 3
|
||||
|
||||
/** Server-side window check — the source of truth even if no one closed the window. */
|
||||
function windowIsOpen(
|
||||
s: { audiencePhase: string; audienceWindowClosesAt: Date | null },
|
||||
now = new Date()
|
||||
) {
|
||||
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
|
||||
}
|
||||
|
||||
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
|
||||
if (key === 'CATEGORY:STARTUP') return 'STARTUP'
|
||||
if (key === 'CATEGORY:BUSINESS_CONCEPT') return 'BUSINESS_CONCEPT'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Finale project order: the cursor system's round.configJson.projectOrder is
|
||||
* the source of truth; session.projectOrderJson is the fallback.
|
||||
*/
|
||||
async function getOrderedFinaleProjects(
|
||||
prisma: PrismaClient,
|
||||
session: { roundId: string | null; projectOrderJson: unknown }
|
||||
) {
|
||||
let order: string[] = []
|
||||
if (session.roundId) {
|
||||
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
|
||||
order = (((round?.configJson as Record<string, unknown>) ?? {}).projectOrder as string[]) ?? []
|
||||
}
|
||||
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
|
||||
if (order.length === 0) return []
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: order } },
|
||||
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||
})
|
||||
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||
return order.map((id) => byId.get(id)).filter((p): p is NonNullable<typeof p> => !!p)
|
||||
}
|
||||
|
||||
export const liveVotingRouter = router({
|
||||
/**
|
||||
* Get or create a live voting session for a round
|
||||
@@ -759,6 +803,7 @@ export const liveVotingRouter = router({
|
||||
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
|
||||
audienceRequireId: z.boolean().optional(),
|
||||
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
|
||||
allowOverallFavorite: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -917,6 +962,297 @@ export const liveVotingRouter = router({
|
||||
return vote
|
||||
}),
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Grand-finale audience favorite-vote windows
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open an audience voting window ("favorite Startup", "favorite Business
|
||||
* Concept", or — if enabled — "overall favorite") for a fixed duration.
|
||||
*/
|
||||
openAudienceWindow: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
windowKey: windowKeySchema,
|
||||
durationMinutes: z.number().int().min(1).max(120).default(5),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
})
|
||||
if (windowIsOpen(session)) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'An audience voting window is already open — close it first',
|
||||
})
|
||||
}
|
||||
if (input.windowKey === 'OVERALL' && !session.allowOverallFavorite) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'The overall favorite vote is not enabled for this session',
|
||||
})
|
||||
}
|
||||
const now = new Date()
|
||||
const updated = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: {
|
||||
audiencePhase: 'OPEN',
|
||||
audienceWindowKey: input.windowKey,
|
||||
audienceWindowOpenedAt: now,
|
||||
audienceWindowClosesAt: new Date(now.getTime() + input.durationMinutes * 60_000),
|
||||
},
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'AUDIENCE_WINDOW_OPENED',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: input.sessionId,
|
||||
detailsJson: { windowKey: input.windowKey, durationMinutes: input.durationMinutes },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close the audience voting window early (allowed at any time).
|
||||
*/
|
||||
closeAudienceWindow: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
})
|
||||
const updated = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: {
|
||||
audiencePhase: 'CLOSED',
|
||||
audienceWindowKey: null,
|
||||
audienceWindowOpenedAt: null,
|
||||
audienceWindowClosesAt: null,
|
||||
},
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'AUDIENCE_WINDOW_CLOSED',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: input.sessionId,
|
||||
detailsJson: { windowKey: session.audienceWindowKey },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Public window state for the audience voting page: open/closed, eligible
|
||||
* projects (in run order), closing time, and the caller's current pick.
|
||||
*/
|
||||
getAudienceWindow: publicProcedure
|
||||
.input(z.object({ sessionId: z.string(), token: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
select: {
|
||||
id: true,
|
||||
roundId: true,
|
||||
projectOrderJson: true,
|
||||
audiencePhase: true,
|
||||
audienceWindowKey: true,
|
||||
audienceWindowClosesAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
const open = windowIsOpen(session)
|
||||
const windowKey = open ? session.audienceWindowKey : null
|
||||
let projects: Awaited<ReturnType<typeof getOrderedFinaleProjects>> = []
|
||||
if (open && windowKey) {
|
||||
const cat = categoryForKey(windowKey)
|
||||
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
|
||||
projects = cat ? ordered.filter((p) => p.competitionCategory === cat) : ordered
|
||||
}
|
||||
let myVoteProjectId: string | null = null
|
||||
if (input.token && windowKey) {
|
||||
const voter = await ctx.prisma.audienceVoter.findUnique({
|
||||
where: { token: input.token },
|
||||
})
|
||||
if (voter && voter.sessionId === input.sessionId) {
|
||||
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
|
||||
where: {
|
||||
sessionId_windowKey_audienceVoterId: {
|
||||
sessionId: input.sessionId,
|
||||
windowKey,
|
||||
audienceVoterId: voter.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
myVoteProjectId = existing?.projectId ?? null
|
||||
}
|
||||
}
|
||||
return {
|
||||
open,
|
||||
windowKey,
|
||||
closesAt: open ? session.audienceWindowClosesAt : null,
|
||||
projects,
|
||||
myVoteProjectId,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cast (or change) a pick-one-favorite vote in the open window.
|
||||
* Gates, in order: token, window open, time, eligibility, category, IP cap.
|
||||
*/
|
||||
castFavoriteVote: publicProcedure
|
||||
.input(z.object({ sessionId: z.string(), token: z.string(), projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const voter = await ctx.prisma.audienceVoter.findUnique({
|
||||
where: { token: input.token },
|
||||
})
|
||||
if (!voter || voter.sessionId !== input.sessionId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid voting token' })
|
||||
}
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
})
|
||||
if (!windowIsOpen(session) || !session.audienceWindowKey) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Voting is not open right now',
|
||||
})
|
||||
}
|
||||
const windowKey = session.audienceWindowKey
|
||||
|
||||
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
|
||||
const project = ordered.find((p) => p.id === input.projectId)
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not part of this vote' })
|
||||
}
|
||||
const cat = categoryForKey(windowKey)
|
||||
if (cat && project.competitionCategory !== cat) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Project is not in the category currently open for voting',
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
|
||||
where: {
|
||||
sessionId_windowKey_audienceVoterId: {
|
||||
sessionId: input.sessionId,
|
||||
windowKey,
|
||||
audienceVoterId: voter.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
// IP cap only gates NEW voters — an existing voter may always update.
|
||||
if (!existing && ctx.ip) {
|
||||
const ipCount = await ctx.prisma.audienceFavoriteVote.count({
|
||||
where: { sessionId: input.sessionId, windowKey, ipAddress: ctx.ip },
|
||||
})
|
||||
if (ipCount >= MAX_FAVORITE_VOTERS_PER_IP) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: 'Vote limit reached for this network connection',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const vote = await ctx.prisma.audienceFavoriteVote.upsert({
|
||||
where: {
|
||||
sessionId_windowKey_audienceVoterId: {
|
||||
sessionId: input.sessionId,
|
||||
windowKey,
|
||||
audienceVoterId: voter.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
sessionId: input.sessionId,
|
||||
windowKey,
|
||||
projectId: input.projectId,
|
||||
audienceVoterId: voter.id,
|
||||
ipAddress: ctx.ip ?? null,
|
||||
},
|
||||
update: {
|
||||
projectId: input.projectId,
|
||||
ipAddress: ctx.ip ?? existing?.ipAddress ?? null,
|
||||
},
|
||||
})
|
||||
return { projectId: vote.projectId, windowKey }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Per-window per-project favorite-vote tallies (admin only — the big screen
|
||||
* shows only the total count).
|
||||
*/
|
||||
getFavoriteTallies: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const grouped = await ctx.prisma.audienceFavoriteVote.groupBy({
|
||||
by: ['windowKey', 'projectId'],
|
||||
where: { sessionId: input.sessionId },
|
||||
_count: { _all: true },
|
||||
})
|
||||
const projectIds = [...new Set(grouped.map((g) => g.projectId))]
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: { id: true, title: true, teamName: true },
|
||||
})
|
||||
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||
const windowKeys = [...new Set(grouped.map((g) => g.windowKey))]
|
||||
const windows = windowKeys.map((windowKey) => {
|
||||
const rows = grouped.filter((g) => g.windowKey === windowKey)
|
||||
return {
|
||||
windowKey,
|
||||
totalVotes: rows.reduce((sum, r) => sum + r._count._all, 0),
|
||||
projects: rows
|
||||
.map((r) => ({
|
||||
projectId: r.projectId,
|
||||
title: byId.get(r.projectId)?.title ?? 'Unknown',
|
||||
teamName: byId.get(r.projectId)?.teamName ?? null,
|
||||
count: r._count._all,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
}
|
||||
})
|
||||
return { windows }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resolve the live voting session for a round (public — the audience page
|
||||
* only knows the roundId from the QR code URL).
|
||||
*/
|
||||
getAudienceContextByRound: publicProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||
where: { roundId: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
allowAudienceVotes: true,
|
||||
round: {
|
||||
select: {
|
||||
name: true,
|
||||
competition: {
|
||||
select: { program: { select: { name: true, year: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!session) return null
|
||||
return {
|
||||
sessionId: session.id,
|
||||
allowAudienceVotes: session.allowAudienceVotes,
|
||||
roundName: session.round?.name ?? null,
|
||||
programName: session.round?.competition?.program?.name ?? null,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audience voter stats (admin)
|
||||
*/
|
||||
|
||||
277
tests/unit/audience-window.test.ts
Normal file
277
tests/unit/audience-window.test.ts
Normal 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 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 } })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user