From f34b062f2a35c1152825831edb746c981d6d9a62 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 19:03:34 +0100 Subject: [PATCH] fix(events): enforce series-pass, hidden, and deadline gates Pre-launch P0 fixes surfaced by docs/specs/events-functional-test-matrix.md (Findings 1, 2, 3). 1. Series-pass bypass (Finding 1 / matrix S1 P3): register.post.js now loads the linked Series when tickets.requiresSeriesTicket is set and rejects drop-in registration unless series.allowIndividualEventTickets is true or the user has a valid pass. Data-integrity 500 if the referenced series is missing. 2. Hidden-event leak (Finding 2 / matrix E11): extract loadPublicEvent into server/utils/loadEvent.js. All five public event endpoints ([id].get, register, tickets/available, tickets/reserve, tickets/purchase) now go through the helper, which 404s when isVisible === false and the requester is not an admin. Admin detection uses a new non-throwing getOptionalMember() in server/utils/auth.js (extracted from the pattern already inlined in api/auth/status.get.js). 3. Deadline enforcement + legacy pricing retirement (Finding 3 / matrix E8): register.post.js and tickets/reserve.post.js delegate gating to validateTicketPurchase (which already covers deadline, cancelled, started, members-only, sold-out, and already-registered); tickets/available.get.js gets an explicit registrationDeadline check. Legacy pricing.paymentRequired 402 branch removed from register.post.js. --- server/api/events/[id].get.js | 60 +---- server/api/events/[id]/register.post.js | 127 ++++------ .../api/events/[id]/tickets/available.get.js | 70 ++---- .../api/events/[id]/tickets/purchase.post.js | 31 +-- .../api/events/[id]/tickets/reserve.post.js | 57 ++--- server/utils/auth.js | 26 ++ server/utils/loadEvent.js | 55 +++++ tests/server/api/event-registration.test.js | 10 +- tests/server/api/events/deadline.test.js | 226 ++++++++++++++++++ .../server/api/events/register-series.test.js | 205 ++++++++++++++++ tests/server/utils/loadEvent.test.js | 126 ++++++++++ 11 files changed, 746 insertions(+), 247 deletions(-) create mode 100644 server/utils/loadEvent.js create mode 100644 tests/server/api/events/deadline.test.js create mode 100644 tests/server/api/events/register-series.test.js create mode 100644 tests/server/utils/loadEvent.test.js diff --git a/server/api/events/[id].get.js b/server/api/events/[id].get.js index f0822ba..5cbd329 100644 --- a/server/api/events/[id].get.js +++ b/server/api/events/[id].get.js @@ -1,65 +1,29 @@ -import Event from '../../models/event.js' -import { connectDB } from '../../utils/mongoose.js' -import mongoose from 'mongoose' +import { loadPublicEvent } from '../../utils/loadEvent.js' export default defineEventHandler(async (event) => { try { - // Ensure database connection - await connectDB() const identifier = getRouterParam(event, 'id') - - if (!identifier) { - throw createError({ - statusCode: 400, - statusMessage: 'Event identifier is required' - }) - } - - // Fetch event from database - try by slug first, then by ID - let eventData - - // Check if identifier is a valid MongoDB ObjectId - if (mongoose.Types.ObjectId.isValid(identifier)) { - eventData = await Event.findById(identifier) - .select('-registrations.email') // Hide emails for privacy - .lean() - } - - // If not found by ID or not a valid ObjectId, try by slug - if (!eventData) { - eventData = await Event.findOne({ slug: identifier }) - .select('-registrations.email') // Hide emails for privacy - .lean() - } - - if (!eventData) { - throw createError({ - statusCode: 404, - statusMessage: 'Event not found' - }) - } - - // Add computed fields - const eventWithMeta = { + const eventData = await loadPublicEvent(event, identifier, { + lean: true, + select: '-registrations.email' + }) + + return { ...eventData, id: eventData._id.toString(), registeredCount: eventData.registrations?.length || 0, - isFull: eventData.maxAttendees ? - (eventData.registrations?.length || 0) >= eventData.maxAttendees : - false + isFull: eventData.maxAttendees + ? (eventData.registrations?.length || 0) >= eventData.maxAttendees + : false } - - return eventWithMeta } catch (error) { - console.error('Error fetching event:', error) - if (error.statusCode) { throw error } - + console.error('Error fetching event:', error) throw createError({ statusCode: 500, statusMessage: 'Failed to fetch event' }) } -}) \ No newline at end of file +}) diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js index 7f6685f..41d7a3a 100644 --- a/server/api/events/[id]/register.post.js +++ b/server/api/events/[id]/register.post.js @@ -1,97 +1,70 @@ -import Event from "../../../models/event.js"; import Member from "../../../models/member.js"; -import { connectDB } from "../../../utils/mongoose.js"; +import Series from "../../../models/series.js"; +import Event from "../../../models/event.js"; import { sendEventRegistrationEmail } from "../../../utils/resend.js"; import { validateBody } from "../../../utils/validateBody.js"; import { eventRegistrationSchema } from "../../../utils/schemas.js"; -import { hasMemberAccess } from "../../../utils/tickets.js"; -import mongoose from "mongoose"; +import { loadPublicEvent } from "../../../utils/loadEvent.js"; +import { + hasMemberAccess, + validateTicketPurchase, + checkUserSeriesPass, +} from "../../../utils/tickets.js"; export default defineEventHandler(async (event) => { try { - // Ensure database connection - await connectDB(); const identifier = getRouterParam(event, "id"); const body = await validateBody(event, eventRegistrationSchema); - if (!identifier) { - throw createError({ - statusCode: 400, - statusMessage: "Event identifier is required", - }); - } + const eventData = await loadPublicEvent(event, identifier); - // Fetch the event - try by slug first, then by ID - let eventData; - - // Check if identifier is a valid MongoDB ObjectId - if (mongoose.Types.ObjectId.isValid(identifier)) { - eventData = await Event.findById(identifier); - } - - // If not found by ID or not a valid ObjectId, try by slug - if (!eventData) { - eventData = await Event.findOne({ slug: identifier }); - } - - if (!eventData) { - throw createError({ - statusCode: 404, - statusMessage: "Event not found", - }); - } - - // Check if event is full + // Series-pass gate: when an event is linked to a series and that series + // requires a pass, reject drop-in registration unless the series + // explicitly allows individual event tickets. if ( - eventData.maxAttendees && - eventData.registrations.length >= eventData.maxAttendees + eventData.tickets?.requiresSeriesTicket && + eventData.tickets?.seriesTicketReference ) { - throw createError({ - statusCode: 400, - statusMessage: "Event is full", - }); + const series = await Series.findById( + eventData.tickets.seriesTicketReference, + ); + if (!series) { + throw createError({ + statusCode: 500, + statusMessage: "Series referenced by this event could not be found", + }); + } + if (!series.allowIndividualEventTickets) { + const { hasPass } = checkUserSeriesPass(series, body.email); + if (!hasPass) { + throw createError({ + statusCode: 403, + statusMessage: + "This event requires a series pass. See the series page to purchase.", + data: { seriesSlug: series.slug }, + }); + } + } } - // Check if already registered - const alreadyRegistered = eventData.registrations.some( - (reg) => reg.email.toLowerCase() === body.email.toLowerCase(), - ); - - if (alreadyRegistered) { - throw createError({ - statusCode: 400, - statusMessage: "You are already registered for this event", - }); - } - - // Check member status and handle different registration scenarios. - // Member access is decoupled from payment status: active and pending_payment - // both confer access; guest, suspended, and cancelled do not. + // Look up member for pricing/access purposes. hasMemberAccess treats + // guest/suspended/cancelled as non-members. const member = await Member.findOne({ email: body.email.toLowerCase() }); + + // Single gate: validateTicketPurchase covers deadline, cancelled, already + // started, already-registered, members-only, and sold-out. + const validation = validateTicketPurchase(eventData, { + email: body.email, + name: body.name, + member: hasMemberAccess(member) ? member : null, + }); + + if (!validation.valid) { + const statusCode = /members only/i.test(validation.reason) ? 403 : 400; + throw createError({ statusCode, statusMessage: validation.reason }); + } + const memberHasAccess = hasMemberAccess(member); - - if (eventData.membersOnly && !memberHasAccess) { - throw createError({ - statusCode: 403, - statusMessage: - "This event is for members only. Please become a member to register.", - }); - } - - // If event requires payment and user is not a member, redirect to payment flow - if ( - eventData.pricing?.paymentRequired && - !eventData.pricing?.isFree && - !memberHasAccess - ) { - throw createError({ - statusCode: 402, // Payment Required - statusMessage: - "This event requires payment. Please use the payment registration endpoint.", - }); - } - - // Set member status and membership level let isMember = false; let membershipLevel = "non-member"; diff --git a/server/api/events/[id]/tickets/available.get.js b/server/api/events/[id]/tickets/available.get.js index 51ec4e9..4107c0a 100644 --- a/server/api/events/[id]/tickets/available.get.js +++ b/server/api/events/[id]/tickets/available.get.js @@ -1,12 +1,10 @@ -import Event from "../../../../models/event.js"; import Member from "../../../../models/member.js"; -import { connectDB } from "../../../../utils/mongoose.js"; +import { loadPublicEvent } from "../../../../utils/loadEvent.js"; import { calculateTicketPrice, checkTicketAvailability, formatPrice, } from "../../../../utils/tickets.js"; -import mongoose from "mongoose"; /** * GET /api/events/[id]/tickets/available @@ -15,56 +13,32 @@ import mongoose from "mongoose"; */ export default defineEventHandler(async (event) => { try { - await connectDB(); const identifier = getRouterParam(event, "id"); const query = getQuery(event); const userEmail = query.email; - if (!identifier) { - throw createError({ - statusCode: 400, - statusMessage: "Event identifier is required", - }); - } + const eventData = await loadPublicEvent(event, identifier); - // Fetch the event - let eventData; - if (mongoose.Types.ObjectId.isValid(identifier)) { - eventData = await Event.findById(identifier); - } - if (!eventData) { - eventData = await Event.findOne({ slug: identifier }); - } - - if (!eventData) { - throw createError({ - statusCode: 404, - statusMessage: "Event not found", - }); - } - - // Check if event is cancelled or past if (eventData.isCancelled) { - return { - available: false, - reason: "Event has been cancelled", - }; + return { available: false, reason: "Event has been cancelled" }; } if (new Date(eventData.startDate) < new Date()) { - return { - available: false, - reason: "Event has already started", - }; + return { available: false, reason: "Event has already started" }; + } + + if ( + eventData.registrationDeadline && + new Date(eventData.registrationDeadline) < new Date() + ) { + return { available: false, reason: "Registration deadline has passed" }; } - // Check if user is a member (if email provided) let member = null; if (userEmail) { member = await Member.findOne({ email: userEmail.toLowerCase() }); } - // Calculate ticket pricing for this user const ticketInfo = calculateTicketPrice(eventData, member); if (!ticketInfo) { @@ -77,13 +51,11 @@ export default defineEventHandler(async (event) => { }; } - // Check availability const availability = checkTicketAvailability( eventData, - ticketInfo.ticketType + ticketInfo.ticketType, ); - // Build response const response = { available: availability.available, ticketType: ticketInfo.ticketType, @@ -98,38 +70,32 @@ export default defineEventHandler(async (event) => { waitlistAvailable: availability.waitlistAvailable, }; - // Add early bird deadline if applicable - if ( - ticketInfo.isEarlyBird && - eventData.tickets?.public?.earlyBirdDeadline - ) { + if (ticketInfo.isEarlyBird && eventData.tickets?.public?.earlyBirdDeadline) { response.earlyBirdDeadline = eventData.tickets.public.earlyBirdDeadline; response.regularPrice = eventData.tickets.public.price; response.formattedRegularPrice = formatPrice( eventData.tickets.public.price, - ticketInfo.currency + ticketInfo.currency, ); } - // Add member vs public comparison for transparency if (member && eventData.tickets?.public?.available) { response.publicTicket = { price: eventData.tickets.public.price, formattedPrice: formatPrice( eventData.tickets.public.price, - eventData.tickets.currency + eventData.tickets.currency, ), }; response.memberSavings = eventData.tickets.public.price - ticketInfo.price; } - // Check if user is already registered if (userEmail) { const alreadyRegistered = eventData.registrations?.some( (reg) => reg.email.toLowerCase() === userEmail.toLowerCase() && - !reg.cancelledAt + !reg.cancelledAt, ); if (alreadyRegistered) { @@ -141,12 +107,10 @@ export default defineEventHandler(async (event) => { return response; } catch (error) { - console.error("Error checking ticket availability:", error); - if (error.statusCode) { throw error; } - + console.error("Error checking ticket availability:", error); throw createError({ statusCode: 500, statusMessage: "Failed to check ticket availability", diff --git a/server/api/events/[id]/tickets/purchase.post.js b/server/api/events/[id]/tickets/purchase.post.js index 571d813..ac64944 100644 --- a/server/api/events/[id]/tickets/purchase.post.js +++ b/server/api/events/[id]/tickets/purchase.post.js @@ -1,14 +1,13 @@ -import Event from "../../../../models/event.js"; import Member from "../../../../models/member.js"; -import { connectDB } from "../../../../utils/mongoose.js"; +import { loadPublicEvent } from "../../../../utils/loadEvent.js"; +import { validateBody } from "../../../../utils/validateBody.js"; +import { ticketPurchaseSchema } from "../../../../utils/schemas.js"; import { validateTicketPurchase, - calculateTicketPrice, completeTicketPurchase, hasMemberAccess, } from "../../../../utils/tickets.js"; import { sendEventRegistrationEmail } from "../../../../utils/resend.js"; -import mongoose from "mongoose"; /** * POST /api/events/[id]/tickets/purchase @@ -17,32 +16,10 @@ import mongoose from "mongoose"; */ export default defineEventHandler(async (event) => { try { - await connectDB(); const identifier = getRouterParam(event, "id"); const body = await validateBody(event, ticketPurchaseSchema); - if (!identifier) { - throw createError({ - statusCode: 400, - statusMessage: "Event identifier is required", - }); - } - - // Fetch the event - let eventData; - if (mongoose.Types.ObjectId.isValid(identifier)) { - eventData = await Event.findById(identifier); - } - if (!eventData) { - eventData = await Event.findOne({ slug: identifier }); - } - - if (!eventData) { - throw createError({ - statusCode: 404, - statusMessage: "Event not found", - }); - } + const eventData = await loadPublicEvent(event, identifier); // Check if user is a member. Only members with access (active or // pending_payment) count for pricing/validation; guest, suspended, diff --git a/server/api/events/[id]/tickets/reserve.post.js b/server/api/events/[id]/tickets/reserve.post.js index 92d7fb6..4ae5b5f 100644 --- a/server/api/events/[id]/tickets/reserve.post.js +++ b/server/api/events/[id]/tickets/reserve.post.js @@ -1,11 +1,13 @@ -import Event from "../../../../models/event.js"; import Member from "../../../../models/member.js"; -import { connectDB } from "../../../../utils/mongoose.js"; +import { loadPublicEvent } from "../../../../utils/loadEvent.js"; +import { validateBody } from "../../../../utils/validateBody.js"; +import { ticketReserveSchema } from "../../../../utils/schemas.js"; import { calculateTicketPrice, + hasMemberAccess, reserveTicket, + validateTicketPurchase, } from "../../../../utils/tickets.js"; -import mongoose from "mongoose"; /** * POST /api/events/[id]/tickets/reserve @@ -14,47 +16,28 @@ import mongoose from "mongoose"; */ export default defineEventHandler(async (event) => { try { - await connectDB(); const identifier = getRouterParam(event, "id"); const body = await validateBody(event, ticketReserveSchema); - if (!identifier) { - throw createError({ - statusCode: 400, - statusMessage: "Event identifier is required", - }); - } + const eventData = await loadPublicEvent(event, identifier); - // Fetch the event - let eventData; - if (mongoose.Types.ObjectId.isValid(identifier)) { - eventData = await Event.findById(identifier); - } - if (!eventData) { - eventData = await Event.findOne({ slug: identifier }); - } - - if (!eventData) { - throw createError({ - statusCode: 404, - statusMessage: "Event not found", - }); - } - - // Check if user is a member const member = await Member.findOne({ email: body.email.toLowerCase() }); - // Calculate ticket type - const ticketInfo = calculateTicketPrice(eventData, member); + // Gate on deadline, cancellation, start, members-only, sold-out, and + // already-registered before reserving. + const validation = validateTicketPurchase(eventData, { + email: body.email, + member: hasMemberAccess(member) ? member : null, + }); - if (!ticketInfo) { - throw createError({ - statusCode: 400, - statusMessage: "No tickets available for your membership status", - }); + if (!validation.valid) { + const statusCode = /members only/i.test(validation.reason) ? 403 : 400; + throw createError({ statusCode, statusMessage: validation.reason }); } - // Reserve the ticket + const ticketInfo = + validation.ticketInfo || calculateTicketPrice(eventData, member); + const reservation = await reserveTicket(eventData, ticketInfo.ticketType); if (!reservation.success) { @@ -73,12 +56,10 @@ export default defineEventHandler(async (event) => { }, }; } catch (error) { - console.error("Error reserving ticket:", error); - if (error.statusCode) { throw error; } - + console.error("Error reserving ticket:", error); throw createError({ statusCode: 500, statusMessage: "Failed to reserve ticket", diff --git a/server/utils/auth.js b/server/utils/auth.js index d3a5987..1856152 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -75,6 +75,32 @@ export async function requireAuth(event) { return member } +/** + * Return the signed-in Member without throwing. + * Returns null when no cookie, invalid token, or member not found/inactive. + * Mirrors the non-throwing JWT check in api/auth/status.get.js. + */ +export async function getOptionalMember(event) { + await connectDB() + + const token = getCookie(event, 'auth-token') + if (!token) return null + + let decoded + try { + decoded = jwt.verify(token, useRuntimeConfig(event).jwtSecret) + } catch { + return null + } + + const member = await Member.findById(decoded.memberId) + if (!member) return null + if (member.status === 'suspended' || member.status === 'cancelled') return null + if (decoded.tv !== member.tokenVersion) return null + + return member +} + /** * Verify JWT and require admin role. * Throws 401 if not authenticated, 403 if not admin. diff --git a/server/utils/loadEvent.js b/server/utils/loadEvent.js new file mode 100644 index 0000000..e2bb4dc --- /dev/null +++ b/server/utils/loadEvent.js @@ -0,0 +1,55 @@ +import mongoose from 'mongoose' +import Event from '../models/event.js' +import { connectDB } from './mongoose.js' +import { getOptionalMember } from './auth.js' + +/** + * Load an event by ObjectId or slug for public (non-admin) endpoints. + * Enforces the isVisible gate: hidden events 404 for everyone except admins. + * + * @param {Object} reqEvent - h3 event (for auth/cookie access) + * @param {String} identifier - ObjectId string or slug + * @param {Object} [options] + * @param {Boolean} [options.lean] - apply .lean() to the query + * @param {String} [options.select] - apply .select() to the query + * @returns {Promise} the event document + */ +export async function loadPublicEvent(reqEvent, identifier, options = {}) { + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Event identifier is required' + }) + } + + await connectDB() + + const { lean = false, select = null } = options + + const applyOptions = (query) => { + if (select) query = query.select(select) + if (lean) query = query.lean() + return query + } + + let eventDoc + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventDoc = await applyOptions(Event.findById(identifier)) + } + if (!eventDoc) { + eventDoc = await applyOptions(Event.findOne({ slug: identifier })) + } + + if (!eventDoc) { + throw createError({ statusCode: 404, statusMessage: 'Event not found' }) + } + + if (eventDoc.isVisible === false) { + const requester = await getOptionalMember(reqEvent) + if (requester?.role !== 'admin') { + throw createError({ statusCode: 404, statusMessage: 'Event not found' }) + } + } + + return eventDoc +} diff --git a/tests/server/api/event-registration.test.js b/tests/server/api/event-registration.test.js index ffa8f88..c008ad9 100644 --- a/tests/server/api/event-registration.test.js +++ b/tests/server/api/event-registration.test.js @@ -15,12 +15,14 @@ describe('register.post.js', () => { expect(source).toContain('email.toLowerCase()') }) - it('checks membersOnly restriction', () => { - expect(source).toContain('membersOnly') + it('uses validateTicketPurchase for gating (covers membersOnly, deadline, sold-out, etc.)', () => { + expect(source).toContain('validateTicketPurchase(') }) - it('checks capacity via maxAttendees', () => { - expect(source).toContain('maxAttendees') + it('enforces series-pass gate via checkUserSeriesPass', () => { + expect(source).toContain('checkUserSeriesPass(') + expect(source).toContain('requiresSeriesTicket') + expect(source).toContain('seriesTicketReference') }) it('does not let email failure block registration', () => { diff --git a/tests/server/api/events/deadline.test.js b/tests/server/api/events/deadline.test.js new file mode 100644 index 0000000..e49fe73 --- /dev/null +++ b/tests/server/api/events/deadline.test.js @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import registerHandler from '../../../../server/api/events/[id]/register.post.js' +import reserveHandler from '../../../../server/api/events/[id]/tickets/reserve.post.js' +import availableHandler from '../../../../server/api/events/[id]/tickets/available.get.js' +import { createMockEvent } from '../../helpers/createMockEvent.js' + +const { + mockLoadPublicEvent, + mockMemberFindOne, + mockFindByIdAndUpdate, + mockReserveTicket +} = vi.hoisted(() => ({ + mockLoadPublicEvent: vi.fn(), + mockMemberFindOne: vi.fn(), + mockFindByIdAndUpdate: vi.fn(), + mockReserveTicket: vi.fn() +})) + +vi.mock('../../../../server/models/event.js', () => ({ + default: { findByIdAndUpdate: mockFindByIdAndUpdate } +})) + +vi.mock('../../../../server/models/member.js', () => ({ + default: { findOne: mockMemberFindOne } +})) + +vi.mock('../../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +vi.mock('../../../../server/utils/loadEvent.js', () => ({ + loadPublicEvent: mockLoadPublicEvent +})) + +vi.mock('../../../../server/utils/resend.js', () => ({ + sendEventRegistrationEmail: vi.fn().mockResolvedValue(null) +})) + +vi.mock('../../../../server/utils/tickets.js', async () => { + const actual = await vi.importActual('../../../../server/utils/tickets.js') + return { + ...actual, + reserveTicket: mockReserveTicket + } +}) + +const future = (ms) => new Date(Date.now() + ms) +const past = (ms) => new Date(Date.now() - ms) + +function baseFreeEvent(overrides = {}) { + return { + _id: 'event-1', + slug: 'deadline-event', + title: 'Deadline Test', + startDate: future(3 * 86400000), + isCancelled: false, + registrations: [], + tickets: { enabled: false }, + isVisible: true, + ...overrides + } +} + +function baseTicketedEvent(overrides = {}) { + return { + _id: 'event-ticketed', + slug: 'ticketed-event', + title: 'Ticketed', + startDate: future(3 * 86400000), + isCancelled: false, + registrations: [], + tickets: { + enabled: true, + currency: 'CAD', + capacity: { total: 50 }, + member: { available: true, price: 0, isFree: true, name: 'Member' }, + public: { available: true, price: 15, quantity: 50, sold: 0, reserved: 0, name: 'GA' }, + waitlist: { enabled: false } + }, + isVisible: true, + ...overrides + } +} + +function mkReq({ method = 'POST', path = '/', body, query = '', id = 'event-slug' } = {}) { + const req = createMockEvent({ method, path: path + (query ? `?${query}` : ''), body }) + req.context = { params: { id } } + return req +} + +describe('Registration deadline — register.post.js', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMemberFindOne.mockResolvedValue(null) + mockFindByIdAndUpdate.mockImplementation((id, update) => + Promise.resolve({ registrations: [{ ...update.$push.registrations, _id: 'r1' }] }) + ) + }) + + it('returns 400 with deadline reason when registrationDeadline has passed', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseFreeEvent({ registrationDeadline: past(86400000) }) + ) + + const req = mkReq({ + path: '/api/events/deadline-event/register', + body: { email: 'a@example.com', name: 'A' } + }) + + await expect(registerHandler(req)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: expect.stringMatching(/deadline/i) + }) + expect(mockFindByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('succeeds on happy path when deadline is in the future', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseFreeEvent({ registrationDeadline: future(86400000) }) + ) + + const req = mkReq({ + path: '/api/events/deadline-event/register', + body: { email: 'a@example.com', name: 'A' } + }) + + const result = await registerHandler(req) + expect(result.success).toBe(true) + }) + + it('no longer routes tickets.enabled events through legacy pricing 402', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseTicketedEvent({ + pricing: { paymentRequired: true, isFree: false } + }) + ) + + const req = mkReq({ + path: '/api/events/ticketed-event/register', + body: { email: 'a@example.com', name: 'A' } + }) + + const result = await registerHandler(req) + expect(result.success).toBe(true) + }) +}) + +describe('Registration deadline — reserve.post.js', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMemberFindOne.mockResolvedValue(null) + mockReserveTicket.mockResolvedValue({ + success: true, + reservationId: 'res-1', + expiresAt: new Date(Date.now() + 600000) + }) + }) + + it('returns 400 with deadline reason when registrationDeadline has passed', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseTicketedEvent({ registrationDeadline: past(86400000) }) + ) + + const req = mkReq({ + path: '/api/events/ticketed-event/tickets/reserve', + body: { email: 'a@example.com' } + }) + + await expect(reserveHandler(req)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: expect.stringMatching(/deadline/i) + }) + expect(mockReserveTicket).not.toHaveBeenCalled() + }) + + it('succeeds when deadline is in the future', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseTicketedEvent({ registrationDeadline: future(86400000) }) + ) + + const req = mkReq({ + path: '/api/events/ticketed-event/tickets/reserve', + body: { email: 'a@example.com' } + }) + + const result = await reserveHandler(req) + expect(result.success).toBe(true) + }) +}) + +describe('Registration deadline — available.get.js', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMemberFindOne.mockResolvedValue(null) + }) + + it('returns available:false with deadline reason when deadline has passed', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseTicketedEvent({ registrationDeadline: past(86400000) }) + ) + + const req = mkReq({ + method: 'GET', + path: '/api/events/ticketed-event/tickets/available' + }) + + const result = await availableHandler(req) + expect(result.available).toBe(false) + expect(result.reason).toMatch(/deadline/i) + }) + + it('returns availability normally when deadline is in the future', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseTicketedEvent({ registrationDeadline: future(86400000) }) + ) + + const req = mkReq({ + method: 'GET', + path: '/api/events/ticketed-event/tickets/available' + }) + + const result = await availableHandler(req) + expect(result.available).toBe(true) + }) +}) diff --git a/tests/server/api/events/register-series.test.js b/tests/server/api/events/register-series.test.js new file mode 100644 index 0000000..892ef3b --- /dev/null +++ b/tests/server/api/events/register-series.test.js @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import handler from '../../../../server/api/events/[id]/register.post.js' +import { createMockEvent } from '../../helpers/createMockEvent.js' + +const { + mockLoadPublicEvent, + mockMemberFindOne, + mockSeriesFindById, + mockFindByIdAndUpdate, + mockCheckUserSeriesPass +} = vi.hoisted(() => ({ + mockLoadPublicEvent: vi.fn(), + mockMemberFindOne: vi.fn(), + mockSeriesFindById: vi.fn(), + mockFindByIdAndUpdate: vi.fn(), + mockCheckUserSeriesPass: vi.fn() +})) + +vi.mock('../../../../server/models/event.js', () => ({ + default: { findByIdAndUpdate: mockFindByIdAndUpdate } +})) + +vi.mock('../../../../server/models/member.js', () => ({ + default: { findOne: mockMemberFindOne } +})) + +vi.mock('../../../../server/models/series.js', () => ({ + default: { findById: mockSeriesFindById } +})) + +vi.mock('../../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +vi.mock('../../../../server/utils/loadEvent.js', () => ({ + loadPublicEvent: mockLoadPublicEvent +})) + +vi.mock('../../../../server/utils/resend.js', () => ({ + sendEventRegistrationEmail: vi.fn().mockResolvedValue(null) +})) + +vi.mock('../../../../server/utils/tickets.js', async () => { + const actual = await vi.importActual('../../../../server/utils/tickets.js') + return { + ...actual, + checkUserSeriesPass: mockCheckUserSeriesPass + } +}) + +const futureDate = () => new Date(Date.now() + 86400000) + +const defaultTickets = () => ({ + enabled: true, + currency: 'CAD', + capacity: { total: 100 }, + member: { available: true, price: 0, isFree: true, name: 'Member Ticket' }, + public: { available: true, price: 20, quantity: 50, sold: 0, reserved: 0, name: 'Public Ticket' }, + waitlist: { enabled: false } +}) + +function baseEvent(overrides = {}) { + const { tickets: ticketOverrides, ...rest } = overrides + return { + _id: 'event-1', + slug: 'event-slug', + title: 'Test Event', + startDate: futureDate(), + isCancelled: false, + registrations: [], + tickets: { ...defaultTickets(), ...ticketOverrides }, + isVisible: true, + ...rest + } +} + +function runHandler({ identifier = 'event-slug', body } = {}) { + const req = createMockEvent({ + method: 'POST', + path: `/api/events/${identifier}/register`, + body + }) + req.context = { params: { id: identifier } } + return handler(req) +} + +describe('POST /api/events/[id]/register — series-pass enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMemberFindOne.mockResolvedValue(null) + mockFindByIdAndUpdate.mockImplementation((id, update) => { + const reg = update.$push.registrations + return Promise.resolve({ registrations: [{ ...reg, _id: 'reg-1' }] }) + }) + }) + + it('blocks registration when event requires series pass and user has no pass (drop-in disabled)', async () => { + const seriesId = 'series-1' + mockLoadPublicEvent.mockResolvedValue( + baseEvent({ + tickets: { + enabled: true, + requiresSeriesTicket: true, + seriesTicketReference: seriesId + } + }) + ) + mockSeriesFindById.mockResolvedValue({ + _id: seriesId, + slug: 'series-slug', + allowIndividualEventTickets: false, + registrations: [] + }) + mockCheckUserSeriesPass.mockReturnValue({ hasPass: false, registration: null }) + + await expect( + runHandler({ body: { email: 'guest@example.com', name: 'Guest' } }) + ).rejects.toMatchObject({ + statusCode: 403, + data: { seriesSlug: 'series-slug' } + }) + + expect(mockFindByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('allows registration when user has a valid series pass', async () => { + const seriesId = 'series-1' + mockLoadPublicEvent.mockResolvedValue( + baseEvent({ + tickets: { + enabled: true, + requiresSeriesTicket: true, + seriesTicketReference: seriesId + } + }) + ) + mockSeriesFindById.mockResolvedValue({ + _id: seriesId, + slug: 'series-slug', + allowIndividualEventTickets: false, + registrations: [] + }) + mockCheckUserSeriesPass.mockReturnValue({ + hasPass: true, + registration: { email: 'buyer@example.com' } + }) + + const result = await runHandler({ body: { email: 'buyer@example.com', name: 'Buyer' } }) + + expect(result.success).toBe(true) + expect(mockFindByIdAndUpdate).toHaveBeenCalled() + }) + + it('allows drop-in registration when series permits individual event tickets', async () => { + const seriesId = 'series-2' + mockLoadPublicEvent.mockResolvedValue( + baseEvent({ + tickets: { + enabled: true, + requiresSeriesTicket: true, + seriesTicketReference: seriesId + } + }) + ) + mockSeriesFindById.mockResolvedValue({ + _id: seriesId, + slug: 'series-dropin', + allowIndividualEventTickets: true, + registrations: [] + }) + mockCheckUserSeriesPass.mockReturnValue({ hasPass: false, registration: null }) + + const result = await runHandler({ body: { email: 'dropin@example.com', name: 'DropIn' } }) + + expect(result.success).toBe(true) + expect(mockFindByIdAndUpdate).toHaveBeenCalled() + }) + + it('throws 500 when event references a series that cannot be found (data integrity)', async () => { + mockLoadPublicEvent.mockResolvedValue( + baseEvent({ + tickets: { + enabled: true, + requiresSeriesTicket: true, + seriesTicketReference: 'missing-series' + } + }) + ) + mockSeriesFindById.mockResolvedValue(null) + + await expect( + runHandler({ body: { email: 'guest@example.com', name: 'Guest' } }) + ).rejects.toMatchObject({ statusCode: 500 }) + }) + + it('allows normal registration for events with no series linkage', async () => { + mockLoadPublicEvent.mockResolvedValue(baseEvent()) + + const result = await runHandler({ body: { email: 'solo@example.com', name: 'Solo' } }) + + expect(result.success).toBe(true) + expect(mockSeriesFindById).not.toHaveBeenCalled() + }) +}) diff --git a/tests/server/utils/loadEvent.test.js b/tests/server/utils/loadEvent.test.js new file mode 100644 index 0000000..b984c96 --- /dev/null +++ b/tests/server/utils/loadEvent.test.js @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { loadPublicEvent } from '../../../server/utils/loadEvent.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +const { mockFindById, mockFindOne, mockObjectIdIsValid, mockGetOptionalMember } = vi.hoisted(() => ({ + mockFindById: vi.fn(), + mockFindOne: vi.fn(), + mockObjectIdIsValid: vi.fn(), + mockGetOptionalMember: vi.fn() +})) + +vi.mock('../../../server/models/event.js', () => ({ + default: { findById: mockFindById, findOne: mockFindOne } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +vi.mock('../../../server/utils/auth.js', () => ({ + getOptionalMember: mockGetOptionalMember +})) + +vi.mock('mongoose', () => ({ + default: { + Types: { + ObjectId: { isValid: mockObjectIdIsValid } + } + } +})) + +function chainableResult(value) { + const query = { + select: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + then: (resolve) => Promise.resolve(value).then(resolve) + } + return query +} + +describe('loadPublicEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + mockObjectIdIsValid.mockReturnValue(false) + mockGetOptionalMember.mockResolvedValue(null) + }) + + it('throws 400 when identifier is missing', async () => { + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/' }) + await expect(loadPublicEvent(reqEvent, '')).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('throws 404 when event is not found by slug', async () => { + mockFindOne.mockReturnValue(chainableResult(null)) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/missing' }) + + await expect(loadPublicEvent(reqEvent, 'missing')).rejects.toMatchObject({ statusCode: 404 }) + expect(mockFindOne).toHaveBeenCalledWith({ slug: 'missing' }) + }) + + it('returns visible event for any caller (no admin check)', async () => { + const eventDoc = { _id: 'e1', slug: 'visible-slug', isVisible: true } + mockFindOne.mockReturnValue(chainableResult(eventDoc)) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/visible-slug' }) + + const result = await loadPublicEvent(reqEvent, 'visible-slug') + + expect(result).toBe(eventDoc) + expect(mockGetOptionalMember).not.toHaveBeenCalled() + }) + + it('returns hidden event for admin caller', async () => { + const eventDoc = { _id: 'e1', slug: 'hidden-slug', isVisible: false } + mockFindOne.mockReturnValue(chainableResult(eventDoc)) + mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'admin' }) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/hidden-slug' }) + + const result = await loadPublicEvent(reqEvent, 'hidden-slug') + + expect(result).toBe(eventDoc) + }) + + it('throws 404 on hidden event for non-admin member', async () => { + const eventDoc = { _id: 'e1', slug: 'hidden-slug', isVisible: false } + mockFindOne.mockReturnValue(chainableResult(eventDoc)) + mockGetOptionalMember.mockResolvedValue({ _id: 'm1', role: 'member' }) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/hidden-slug' }) + + await expect(loadPublicEvent(reqEvent, 'hidden-slug')).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('throws 404 on hidden event for unauthenticated caller', async () => { + const eventDoc = { _id: 'e1', slug: 'hidden-slug', isVisible: false } + mockFindOne.mockReturnValue(chainableResult(eventDoc)) + mockGetOptionalMember.mockResolvedValue(null) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/hidden-slug' }) + + await expect(loadPublicEvent(reqEvent, 'hidden-slug')).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('applies select and lean options when provided', async () => { + const eventDoc = { _id: 'e1', slug: 'x', isVisible: true } + const query = chainableResult(eventDoc) + mockFindOne.mockReturnValue(query) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/x' }) + + await loadPublicEvent(reqEvent, 'x', { select: '-registrations.email', lean: true }) + + expect(query.select).toHaveBeenCalledWith('-registrations.email') + expect(query.lean).toHaveBeenCalled() + }) + + it('looks up by ObjectId when identifier is a valid ObjectId', async () => { + const eventDoc = { _id: 'e1', slug: 'x', isVisible: true } + mockObjectIdIsValid.mockReturnValue(true) + mockFindById.mockReturnValue(chainableResult(eventDoc)) + const reqEvent = createMockEvent({ method: 'GET', path: '/api/events/507f1f77bcf86cd799439011' }) + + const result = await loadPublicEvent(reqEvent, '507f1f77bcf86cd799439011') + + expect(mockFindById).toHaveBeenCalledWith('507f1f77bcf86cd799439011') + expect(mockFindOne).not.toHaveBeenCalled() + expect(result).toBe(eventDoc) + }) +})