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.
This commit is contained in:
parent
036af95e00
commit
1e30ba23cd
35 changed files with 3637 additions and 5 deletions
936
tests/server/utils/tickets.test.js
Normal file
936
tests/server/utils/tickets.test.js
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue