Extracts hasMemberAccess(member) in tickets.js and uses it across event registration, ticket purchase, and series purchase flows so guest, suspended, and cancelled records no longer count as members while pending_payment still does.
1068 lines
32 KiB
JavaScript
1068 lines
32 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',
|
|
status: 'active',
|
|
...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()
|
|
})
|
|
})
|
|
|
|
describe('member access by status', () => {
|
|
it('treats pending_payment members as having access (member pricing)', () => {
|
|
const event = ticketedEvent()
|
|
const member = baseMember({ status: 'pending_payment' })
|
|
const result = calculateTicketPrice(event, member)
|
|
|
|
expect(result.ticketType).toBe('member')
|
|
expect(result.price).toBe(10)
|
|
})
|
|
|
|
it('treats guest members as non-members (public pricing)', () => {
|
|
const event = ticketedEvent()
|
|
const member = baseMember({ status: 'guest' })
|
|
const result = calculateTicketPrice(event, member)
|
|
|
|
expect(result.ticketType).toBe('public')
|
|
expect(result.price).toBe(30)
|
|
})
|
|
|
|
it('treats suspended members as non-members (public pricing)', () => {
|
|
const event = ticketedEvent()
|
|
const member = baseMember({ status: 'suspended' })
|
|
const result = calculateTicketPrice(event, member)
|
|
|
|
expect(result.ticketType).toBe('public')
|
|
expect(result.price).toBe(30)
|
|
})
|
|
|
|
it('treats cancelled members as non-members (public pricing)', () => {
|
|
const event = ticketedEvent()
|
|
const member = baseMember({ status: 'cancelled' })
|
|
const result = calculateTicketPrice(event, member)
|
|
|
|
expect(result.ticketType).toBe('public')
|
|
expect(result.price).toBe(30)
|
|
})
|
|
|
|
it('returns null for suspended 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 member = baseMember({ status: 'suspended' })
|
|
const result = calculateTicketPrice(event, member)
|
|
|
|
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('allows pending_payment member for members-only event', () => {
|
|
const event = ticketedEvent({ membersOnly: true })
|
|
const user = {
|
|
email: 'pp@example.com',
|
|
name: 'Pending',
|
|
member: baseMember({ status: 'pending_payment' })
|
|
}
|
|
const result = validateTicketPurchase(event, user)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('rejects suspended member from members-only event', () => {
|
|
const event = ticketedEvent({ membersOnly: true })
|
|
const user = {
|
|
email: 'sus@example.com',
|
|
name: 'Suspended',
|
|
member: baseMember({ status: 'suspended' })
|
|
}
|
|
const result = validateTicketPurchase(event, user)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.reason).toContain('members only')
|
|
})
|
|
|
|
it('rejects cancelled member from members-only event', () => {
|
|
const event = ticketedEvent({ membersOnly: true })
|
|
const user = {
|
|
email: 'can@example.com',
|
|
name: 'Cancelled',
|
|
member: baseMember({ status: 'cancelled' })
|
|
}
|
|
const result = validateTicketPurchase(event, user)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.reason).toContain('members only')
|
|
})
|
|
|
|
it('rejects guest member from members-only event', () => {
|
|
const event = ticketedEvent({ membersOnly: true })
|
|
const user = {
|
|
email: 'guest@example.com',
|
|
name: 'Guest',
|
|
member: baseMember({ status: 'guest' })
|
|
}
|
|
const result = validateTicketPurchase(event, user)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.reason).toContain('members only')
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
it('treats pending_payment members as having access (member pricing)', () => {
|
|
const series = ticketedSeries()
|
|
const member = baseMember({ status: 'pending_payment' })
|
|
const result = calculateSeriesTicketPrice(series, member)
|
|
|
|
expect(result.ticketType).toBe('member')
|
|
expect(result.price).toBe(20)
|
|
})
|
|
|
|
it('treats suspended members as non-members (public pricing)', () => {
|
|
const series = ticketedSeries()
|
|
const member = baseMember({ status: 'suspended' })
|
|
const result = calculateSeriesTicketPrice(series, member)
|
|
|
|
expect(result.ticketType).toBe('public')
|
|
expect(result.price).toBe(60)
|
|
})
|
|
|
|
it('treats cancelled members as non-members (public pricing)', () => {
|
|
const series = ticketedSeries()
|
|
const member = baseMember({ status: 'cancelled' })
|
|
const result = calculateSeriesTicketPrice(series, member)
|
|
|
|
expect(result.ticketType).toBe('public')
|
|
expect(result.price).toBe(60)
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 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()
|
|
})
|
|
})
|