ghostguild-org/tests/server/utils/tickets.test.js
Jennie Robinson Faber 1e30ba23cd
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
feat: add testing infrastructure — Vitest, Playwright, CI, git hooks
Add comprehensive testing covering 420 unit/handler tests across 24 Vitest
files, 9 Playwright E2E specs, accessibility scans, and visual regression.
Includes GitHub Actions CI, Husky pre-push hook, and TESTING.md docs.
2026-04-04 16:07:21 +01:00

936 lines
28 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import {
calculateTicketPrice,
checkTicketAvailability,
validateTicketPurchase,
formatPrice,
calculateSeriesTicketPrice,
checkSeriesTicketAvailability,
validateSeriesTicketPurchase,
checkUserSeriesPass
} from '../../../server/utils/tickets.js'
// ---------------------------------------------------------------------------
// Helpers — build minimal event / series / member stubs
// ---------------------------------------------------------------------------
const futureDate = () => new Date(Date.now() + 86400000).toISOString()
const pastDate = () => new Date(Date.now() - 86400000).toISOString()
const baseMember = (overrides = {}) => ({
email: 'member@example.com',
circle: 'community',
...overrides
})
const legacyFreeEvent = (overrides = {}) => ({
startDate: futureDate(),
registrations: [],
...overrides
})
const legacyPaidEvent = (overrides = {}) => ({
startDate: futureDate(),
registrations: [],
pricing: { paymentRequired: true, isFree: false, publicPrice: 25, currency: 'CAD' },
...overrides
})
const ticketedEvent = (overrides = {}) => ({
startDate: futureDate(),
registrations: [],
tickets: {
enabled: true,
currency: 'CAD',
capacity: { total: 100 },
member: {
available: true,
price: 10,
isFree: false,
name: 'Member Ticket',
description: 'For members'
},
public: {
available: true,
price: 30,
quantity: 50,
sold: 0,
reserved: 0,
name: 'General Admission',
description: 'Public ticket'
},
waitlist: { enabled: false },
...overrides.tickets
},
...overrides
})
const ticketedSeries = (overrides = {}) => ({
isActive: true,
registrations: [],
tickets: {
enabled: true,
currency: 'CAD',
capacity: { total: 50 },
member: {
available: true,
price: 20,
isFree: false,
name: 'Member Series Pass',
description: 'For members'
},
public: {
available: true,
price: 60,
quantity: 30,
sold: 0,
reserved: 0,
name: 'Series Pass',
description: 'Public series pass'
},
waitlist: { enabled: false },
...overrides.tickets
},
...overrides
})
// ===========================================================================
// calculateTicketPrice
// ===========================================================================
describe('calculateTicketPrice', () => {
describe('legacy pricing (tickets not enabled)', () => {
it('returns free guest ticket for events without payment requirement', () => {
const event = legacyFreeEvent()
const result = calculateTicketPrice(event)
expect(result).toEqual({
ticketType: 'guest',
price: 0,
currency: 'CAD',
isEarlyBird: false,
isFree: true
})
})
it('returns free ticket for member on paid event', () => {
const event = legacyPaidEvent()
const member = baseMember()
const result = calculateTicketPrice(event, member)
expect(result.ticketType).toBe('member')
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
it('returns public price for non-member on paid event', () => {
const event = legacyPaidEvent()
const result = calculateTicketPrice(event, null)
expect(result.ticketType).toBe('public')
expect(result.price).toBe(25)
expect(result.isFree).toBe(false)
})
it('returns guest/free when pricing.isFree is true even if paymentRequired', () => {
const event = legacyFreeEvent({
pricing: { paymentRequired: true, isFree: true, publicPrice: 10 }
})
const result = calculateTicketPrice(event)
expect(result.ticketType).toBe('guest')
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
})
describe('member ticket pricing', () => {
it('returns base member price when isFree is false', () => {
const event = ticketedEvent()
const member = baseMember()
const result = calculateTicketPrice(event, member)
expect(result.ticketType).toBe('member')
expect(result.price).toBe(10)
expect(result.isFree).toBe(false)
expect(result.name).toBe('Member Ticket')
expect(result.description).toBe('For members')
})
it('returns price 0 when member ticket isFree is true', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: true, price: 10, isFree: true },
public: { available: true, price: 30 }
}
})
const member = baseMember()
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
it('applies circle override price', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 10,
isFree: false,
circleOverrides: {
founder: { price: 5 }
}
},
public: { available: true, price: 30 }
}
})
const member = baseMember({ circle: 'founder' })
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(5)
expect(result.isFree).toBe(false)
})
it('applies circle override isFree', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 10,
isFree: false,
circleOverrides: {
practitioner: { isFree: true }
}
},
public: { available: true, price: 30 }
}
})
const member = baseMember({ circle: 'practitioner' })
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
it('ignores circle override for a different circle', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 10,
isFree: false,
circleOverrides: {
practitioner: { isFree: true }
}
},
public: { available: true, price: 30 }
}
})
const member = baseMember({ circle: 'community' })
const result = calculateTicketPrice(event, member)
expect(result.price).toBe(10)
expect(result.isFree).toBe(false)
})
})
describe('public ticket pricing', () => {
it('returns base public price when no member provided', () => {
const event = ticketedEvent()
const result = calculateTicketPrice(event, null)
expect(result.ticketType).toBe('public')
expect(result.price).toBe(30)
expect(result.isEarlyBird).toBe(false)
expect(result.isFree).toBe(false)
expect(result.name).toBe('General Admission')
})
it('returns early bird price before deadline', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: {
available: true,
price: 30,
earlyBirdPrice: 20,
earlyBirdDeadline: futureDate()
}
}
})
const result = calculateTicketPrice(event, null)
expect(result.price).toBe(20)
expect(result.isEarlyBird).toBe(true)
expect(result.isFree).toBe(false)
})
it('returns regular price after early bird deadline', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: {
available: true,
price: 30,
earlyBirdPrice: 20,
earlyBirdDeadline: pastDate()
}
}
})
const result = calculateTicketPrice(event, null)
expect(result.price).toBe(30)
expect(result.isEarlyBird).toBe(false)
})
it('returns isFree true when public price is 0', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: { available: true, price: 0 }
}
})
const result = calculateTicketPrice(event, null)
expect(result.price).toBe(0)
expect(result.isFree).toBe(true)
})
})
describe('no tickets available', () => {
it('returns null when member tickets unavailable and no public tickets', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = calculateTicketPrice(event, null)
expect(result).toBeNull()
})
it('returns null for non-member when only member tickets available', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: true, price: 10, isFree: false },
public: { available: false }
}
})
const result = calculateTicketPrice(event, null)
expect(result).toBeNull()
})
})
})
// ===========================================================================
// checkTicketAvailability
// ===========================================================================
describe('checkTicketAvailability', () => {
describe('legacy (tickets not enabled)', () => {
it('returns available with null remaining when no maxAttendees', () => {
const event = legacyFreeEvent()
const result = checkTicketAvailability(event)
expect(result).toEqual({
available: true,
remaining: null,
waitlistAvailable: false
})
})
it('returns available with remaining count when under capacity', () => {
const event = legacyFreeEvent({
maxAttendees: 10,
registrations: [{ email: 'a@b.com' }, { email: 'c@d.com' }]
})
const result = checkTicketAvailability(event)
expect(result.available).toBe(true)
expect(result.remaining).toBe(8)
})
it('returns unavailable at capacity', () => {
const event = legacyFreeEvent({
maxAttendees: 2,
registrations: [{ email: 'a@b.com' }, { email: 'c@d.com' }]
})
const result = checkTicketAvailability(event)
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
})
describe('ticketed events — overall capacity', () => {
it('returns unavailable when total capacity exceeded', () => {
const event = ticketedEvent()
event.tickets.capacity.total = 2
event.registrations = [{ email: 'a@b.com' }, { email: 'c@d.com' }]
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('respects waitlist flag when capacity exceeded', () => {
const event = ticketedEvent()
event.tickets.capacity.total = 1
event.tickets.waitlist = { enabled: true }
event.registrations = [{ email: 'a@b.com' }]
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(false)
expect(result.waitlistAvailable).toBe(true)
})
})
describe('ticketed events — public tickets', () => {
it('returns correct remaining for quantity-limited public tickets', () => {
const event = ticketedEvent()
event.tickets.public.quantity = 10
event.tickets.public.sold = 3
event.tickets.public.reserved = 2
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(true)
expect(result.remaining).toBe(5)
})
it('returns unavailable when public tickets sold out', () => {
const event = ticketedEvent()
event.tickets.public.quantity = 5
event.tickets.public.sold = 5
event.tickets.public.reserved = 0
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('returns unlimited when no quantity set on public tickets', () => {
const event = ticketedEvent()
delete event.tickets.public.quantity
const result = checkTicketAvailability(event, 'public')
expect(result.available).toBe(true)
expect(result.remaining).toBeNull()
})
})
describe('ticketed events — member tickets', () => {
it('returns available with capacity remaining', () => {
const event = ticketedEvent()
event.tickets.capacity.total = 10
event.registrations = [{ email: 'a@b.com' }]
const result = checkTicketAvailability(event, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBe(9)
})
it('returns unlimited when no total capacity set', () => {
const event = ticketedEvent()
delete event.tickets.capacity
const result = checkTicketAvailability(event, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBeNull()
})
})
describe('edge cases', () => {
it('returns unavailable for unknown ticket type', () => {
const event = ticketedEvent()
const result = checkTicketAvailability(event, 'vip')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
})
})
// ===========================================================================
// validateTicketPurchase
// ===========================================================================
describe('validateTicketPurchase', () => {
const validUser = { email: 'user@example.com', name: 'Test User', member: null }
const memberUser = { email: 'member@example.com', name: 'Member', member: baseMember() }
it('rejects cancelled event', () => {
const event = ticketedEvent({ isCancelled: true })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Event has been cancelled')
})
it('rejects past event', () => {
const event = ticketedEvent({ startDate: pastDate() })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Event has already started')
})
it('rejects when registration deadline has passed', () => {
const event = ticketedEvent({ registrationDeadline: pastDate() })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Registration deadline has passed')
})
it('rejects already registered user', () => {
const event = ticketedEvent({
registrations: [{ email: 'user@example.com', cancelledAt: null }]
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('You are already registered for this event')
})
it('allows user whose previous registration was cancelled', () => {
const event = ticketedEvent({
registrations: [{ email: 'user@example.com', cancelledAt: new Date() }]
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(true)
})
it('rejects non-member for members-only event', () => {
const event = ticketedEvent({ membersOnly: true })
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toContain('members only')
})
it('allows member for members-only event', () => {
const event = ticketedEvent({ membersOnly: true })
const result = validateTicketPurchase(event, memberUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo).toBeDefined()
expect(result.availability).toBeDefined()
})
it('rejects when no tickets available for user status', () => {
const event = ticketedEvent({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toContain('No tickets available')
})
it('rejects when sold out with waitlist info', () => {
const event = ticketedEvent()
event.tickets.public.quantity = 1
event.tickets.public.sold = 1
event.tickets.waitlist = { enabled: true }
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Event is sold out')
expect(result.waitlistAvailable).toBe(true)
})
it('returns valid with ticket info and availability for good purchase', () => {
const event = ticketedEvent()
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo.ticketType).toBe('public')
expect(result.ticketInfo.price).toBe(30)
expect(result.availability.available).toBe(true)
})
it('is case-insensitive on email match for duplicate check', () => {
const event = ticketedEvent({
registrations: [{ email: 'USER@EXAMPLE.COM', cancelledAt: null }]
})
const result = validateTicketPurchase(event, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('You are already registered for this event')
})
})
// ===========================================================================
// formatPrice
// ===========================================================================
describe('formatPrice', () => {
it('returns "Free" for zero price', () => {
expect(formatPrice(0)).toBe('Free')
})
it('formats CAD price by default', () => {
const result = formatPrice(25)
expect(result).toContain('25.00')
})
it('formats decimal prices', () => {
const result = formatPrice(9.99)
expect(result).toContain('9.99')
})
it('respects USD currency', () => {
const result = formatPrice(25, 'USD')
expect(result).toContain('25.00')
// US$ or $ prefix depending on locale — just confirm it formats
expect(result).toMatch(/\$/)
})
})
// ===========================================================================
// calculateSeriesTicketPrice
// ===========================================================================
describe('calculateSeriesTicketPrice', () => {
it('returns free guest ticket when tickets not enabled', () => {
const series = { tickets: { enabled: false } }
const result = calculateSeriesTicketPrice(series)
expect(result).toEqual({
ticketType: 'guest',
price: 0,
currency: 'CAD',
isEarlyBird: false,
isFree: true
})
})
it('returns member price for authenticated member', () => {
const series = ticketedSeries()
const member = baseMember()
const result = calculateSeriesTicketPrice(series, member)
expect(result.ticketType).toBe('member')
expect(result.price).toBe(20)
expect(result.isFree).toBe(false)
expect(result.name).toBe('Member Series Pass')
})
it('applies circle override to series member price', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
currency: 'CAD',
member: {
available: true,
price: 20,
isFree: false,
circleOverrides: { founder: { price: 10 } }
},
public: { available: true, price: 60 }
}
})
const member = baseMember({ circle: 'founder' })
const result = calculateSeriesTicketPrice(series, member)
expect(result.price).toBe(10)
})
it('returns public price for non-member', () => {
const series = ticketedSeries()
const result = calculateSeriesTicketPrice(series, null)
expect(result.ticketType).toBe('public')
expect(result.price).toBe(60)
})
it('applies early bird pricing before deadline', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
currency: 'CAD',
member: { available: false },
public: {
available: true,
price: 60,
earlyBirdPrice: 40,
earlyBirdDeadline: futureDate()
}
}
})
const result = calculateSeriesTicketPrice(series, null)
expect(result.price).toBe(40)
expect(result.isEarlyBird).toBe(true)
})
it('returns null when no tickets available for user', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = calculateSeriesTicketPrice(series, null)
expect(result).toBeNull()
})
})
// ===========================================================================
// checkSeriesTicketAvailability
// ===========================================================================
describe('checkSeriesTicketAvailability', () => {
it('returns unavailable when tickets not enabled', () => {
const series = { tickets: { enabled: false } }
const result = checkSeriesTicketAvailability(series)
expect(result.available).toBe(false)
})
it('returns available with remaining for public tickets', () => {
const series = ticketedSeries()
series.tickets.public.quantity = 30
series.tickets.public.sold = 10
series.tickets.public.reserved = 5
const result = checkSeriesTicketAvailability(series, 'public')
expect(result.available).toBe(true)
expect(result.remaining).toBe(15)
})
it('returns unavailable when public tickets sold out', () => {
const series = ticketedSeries()
series.tickets.public.quantity = 5
series.tickets.public.sold = 5
const result = checkSeriesTicketAvailability(series, 'public')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('returns unavailable when total capacity reached', () => {
const series = ticketedSeries()
series.tickets.capacity.total = 2
series.registrations = [
{ email: 'a@b.com' },
{ email: 'c@d.com' }
]
const result = checkSeriesTicketAvailability(series, 'member')
expect(result.available).toBe(false)
expect(result.remaining).toBe(0)
})
it('excludes cancelled registrations from count', () => {
const series = ticketedSeries()
series.tickets.capacity.total = 2
series.registrations = [
{ email: 'a@b.com', cancelledAt: null },
{ email: 'c@d.com', cancelledAt: new Date() }
]
const result = checkSeriesTicketAvailability(series, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBe(1)
})
it('returns unlimited for member tickets with no capacity', () => {
const series = ticketedSeries()
delete series.tickets.capacity
const result = checkSeriesTicketAvailability(series, 'member')
expect(result.available).toBe(true)
expect(result.remaining).toBeNull()
})
})
// ===========================================================================
// validateSeriesTicketPurchase
// ===========================================================================
describe('validateSeriesTicketPurchase', () => {
const validUser = { email: 'user@example.com', name: 'Test User', member: null }
const memberUser = { email: 'member@example.com', name: 'Member', member: baseMember() }
it('rejects inactive series', () => {
const series = ticketedSeries({ isActive: false })
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('This series is not currently available')
})
it('rejects already registered user', () => {
const series = ticketedSeries({
registrations: [{ email: 'user@example.com', cancelledAt: null }]
})
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('You already have a pass for this series')
})
it('allows user whose previous registration was cancelled', () => {
const series = ticketedSeries({
registrations: [{ email: 'user@example.com', cancelledAt: new Date() }]
})
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(true)
})
it('rejects when no tickets available for user status', () => {
const series = ticketedSeries({
tickets: {
enabled: true,
member: { available: false },
public: { available: false }
}
})
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toContain('No series passes available')
})
it('rejects when sold out', () => {
const series = ticketedSeries()
series.tickets.public.quantity = 1
series.tickets.public.sold = 1
series.tickets.waitlist = { enabled: true }
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(false)
expect(result.reason).toBe('Series passes are sold out')
expect(result.waitlistAvailable).toBe(true)
})
it('returns valid with ticket info for good purchase', () => {
const series = ticketedSeries()
const result = validateSeriesTicketPurchase(series, validUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo.ticketType).toBe('public')
expect(result.ticketInfo.price).toBe(60)
expect(result.availability.available).toBe(true)
})
it('returns member ticket info for authenticated member', () => {
const series = ticketedSeries()
const result = validateSeriesTicketPurchase(series, memberUser)
expect(result.valid).toBe(true)
expect(result.ticketInfo.ticketType).toBe('member')
expect(result.ticketInfo.price).toBe(20)
})
})
// ===========================================================================
// checkUserSeriesPass
// ===========================================================================
describe('checkUserSeriesPass', () => {
it('returns hasPass true when user has active registration', () => {
const series = {
registrations: [
{ email: 'user@example.com', cancelledAt: null, paymentStatus: 'completed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(true)
expect(result.registration).toBeDefined()
expect(result.registration.email).toBe('user@example.com')
})
it('returns hasPass false when no registration exists', () => {
const series = { registrations: [] }
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
expect(result.registration).toBeNull()
})
it('returns hasPass false when registration is cancelled', () => {
const series = {
registrations: [
{ email: 'user@example.com', cancelledAt: new Date(), paymentStatus: 'completed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
})
it('returns hasPass false when payment failed', () => {
const series = {
registrations: [
{ email: 'user@example.com', cancelledAt: null, paymentStatus: 'failed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
})
it('is case-insensitive on email', () => {
const series = {
registrations: [
{ email: 'USER@EXAMPLE.COM', cancelledAt: null, paymentStatus: 'completed' }
]
}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(true)
})
it('handles missing registrations array', () => {
const series = {}
const result = checkUserSeriesPass(series, 'user@example.com')
expect(result.hasPass).toBe(false)
expect(result.registration).toBeNull()
})
})