- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
18 KiB
JavaScript
370 lines
18 KiB
JavaScript
const {
|
||
dayKey,
|
||
activeSessions,
|
||
scheduleBounds,
|
||
normalizeSessionsInput,
|
||
sessionAcceptsCheckIn,
|
||
currentSession,
|
||
registrationClosesAt,
|
||
lateInfo,
|
||
latePrice,
|
||
completionMet,
|
||
normalizeReminders,
|
||
normalizeCertificateConfig,
|
||
scanSession,
|
||
redeemedForSession,
|
||
sessionLabel,
|
||
isPickOption,
|
||
expandPickLines,
|
||
ticketScanSession,
|
||
groupTickets,
|
||
wholeCourseClosesAt,
|
||
choosesSessions,
|
||
lineSessions,
|
||
mergeSessionIds,
|
||
ticketDateText,
|
||
} = require('../src/utils/sessions');
|
||
|
||
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
|
||
const wed = (day, h = 16, m = 30) => new Date(Date.UTC(2026, 10, day, h, m)); // November 2026
|
||
const session = (id, day, extra = {}) => ({ id, startsAt: wed(day), endsAt: wed(day, 18, 30), cancelledAt: null, ...extra });
|
||
// Five Wednesdays: 4, 11, 18, 25 Nov and 2 Dec (as day 32 → Date.UTC rolls over).
|
||
const COURSE = [session('s1', 4), session('s2', 11), session('s3', 18), session('s4', 25), session('s5', 32)];
|
||
|
||
describe('dayKey', () => {
|
||
test('uses the church timezone, not UTC', () => {
|
||
expect(dayKey(new Date('2026-11-04T23:30:00Z'))).toBe('2026-11-05'); // 01:30 local next day
|
||
expect(dayKey(new Date('2026-11-04T21:59:00Z'))).toBe('2026-11-04');
|
||
});
|
||
});
|
||
|
||
describe('activeSessions / scheduleBounds', () => {
|
||
test('drops cancelled sessions and sorts by start', () => {
|
||
const list = [session('b', 11), session('a', 4), session('c', 18, { cancelledAt: new Date() })];
|
||
expect(activeSessions(list).map(s => s.id)).toEqual(['a', 'b']);
|
||
});
|
||
|
||
test('bounds run from the first start to the last end', () => {
|
||
const b = scheduleBounds(COURSE);
|
||
expect(b.startDate).toEqual(wed(4));
|
||
expect(b.endDate).toEqual(wed(32, 18, 30));
|
||
});
|
||
|
||
test('a cancelled last session moves the end date back', () => {
|
||
const list = [...COURSE.slice(0, 4), { ...COURSE[4], cancelledAt: new Date() }];
|
||
expect(scheduleBounds(list).endDate).toEqual(wed(25, 18, 30));
|
||
});
|
||
|
||
test('no active sessions → null', () => {
|
||
expect(scheduleBounds([session('a', 4, { cancelledAt: new Date() })])).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('normalizeSessionsInput', () => {
|
||
test('sorts sessions and trims labels', () => {
|
||
const out = normalizeSessionsInput([
|
||
{ startsAt: wed(11), endsAt: wed(11, 18), label: ' Week 2 ' },
|
||
{ startsAt: wed(4), endsAt: wed(4, 18) },
|
||
]);
|
||
expect(out.map(s => s.label)).toEqual([null, 'Week 2']);
|
||
});
|
||
|
||
test('rejects a session that ends before it starts', () => {
|
||
expect(() => normalizeSessionsInput([{ startsAt: wed(4, 18), endsAt: wed(4, 16) }])).toThrow(/must end after/);
|
||
});
|
||
|
||
test('rejects missing dates', () => {
|
||
expect(() => normalizeSessionsInput([{ startsAt: 'nope', endsAt: wed(4) }])).toThrow(/start and end/);
|
||
});
|
||
|
||
test('needs at least one session that is not cancelled', () => {
|
||
expect(() => normalizeSessionsInput([{ startsAt: wed(4), endsAt: wed(4, 18), cancelled: true }])).toThrow(/at least one/);
|
||
expect(() => normalizeSessionsInput([])).toThrow(/at least one/);
|
||
});
|
||
});
|
||
|
||
describe('check-in window', () => {
|
||
const s = session('s1', 4); // 18:30–20:30 local on 4 Nov
|
||
|
||
test('whole day accepts any time on the session date (local)', () => {
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T05:00:00Z'), 'whole_day')).toBe(true); // 07:00 local
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T21:30:00Z'), 'whole_day')).toBe(true); // 23:30 local
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T22:30:00Z'), 'whole_day')).toBe(false); // 00:30 next day
|
||
});
|
||
|
||
test('session only accepts from 2 h before start until the end', () => {
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:29:00Z'), 'session_only')).toBe(false);
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:30:00Z'), 'session_only')).toBe(true);
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:30:00Z'), 'session_only')).toBe(true);
|
||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:31:00Z'), 'session_only')).toBe(false);
|
||
});
|
||
|
||
test('cancelled sessions never accept check-ins', () => {
|
||
expect(sessionAcceptsCheckIn({ ...s, cancelledAt: new Date() }, wed(4), 'whole_day')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('currentSession', () => {
|
||
test('finds today\'s session', () => {
|
||
expect(currentSession(COURSE, new Date('2026-11-11T08:00:00Z')).id).toBe('s2');
|
||
});
|
||
|
||
test('null on a day without a session', () => {
|
||
expect(currentSession(COURSE, new Date('2026-11-12T08:00:00Z'))).toBeNull();
|
||
});
|
||
|
||
test('with two sessions in a day, picks the running one, else the next, else the last', () => {
|
||
const day = [
|
||
{ id: 'am', startsAt: new Date('2026-11-07T07:00:00Z'), endsAt: new Date('2026-11-07T10:00:00Z'), cancelledAt: null },
|
||
{ id: 'pm', startsAt: new Date('2026-11-07T14:00:00Z'), endsAt: new Date('2026-11-07T17:00:00Z'), cancelledAt: null },
|
||
];
|
||
expect(currentSession(day, new Date('2026-11-07T08:00:00Z')).id).toBe('am');
|
||
expect(currentSession(day, new Date('2026-11-07T11:00:00Z')).id).toBe('pm'); // between: next one
|
||
expect(currentSession(day, new Date('2026-11-07T13:00:00Z')).id).toBe('pm'); // within 2 h of pm
|
||
expect(currentSession(day, new Date('2026-11-07T19:00:00Z')).id).toBe('pm'); // after both: last
|
||
expect(currentSession(day, new Date('2026-11-07T04:00:00Z')).id).toBe('am'); // before both: next
|
||
});
|
||
});
|
||
|
||
describe('registrationClosesAt', () => {
|
||
const base = { scheduleType: 'sessions', endDate: wed(32, 18, 30), registrationDeadline: null, lateJoinEnabled: false, lateJoinUntilSessionId: null };
|
||
|
||
test('single-date events close at the end date', () => {
|
||
expect(registrationClosesAt({ ...base, scheduleType: 'single' }, [])).toEqual(wed(32, 18, 30));
|
||
});
|
||
|
||
test('without late joining, closes when the first session starts', () => {
|
||
expect(registrationClosesAt(base, COURSE)).toEqual(wed(4));
|
||
});
|
||
|
||
test('with late joining, closes when the chosen session starts', () => {
|
||
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, COURSE)).toEqual(wed(18));
|
||
});
|
||
|
||
test('an earlier registration deadline wins', () => {
|
||
const deadline = new Date('2026-11-01T00:00:00Z');
|
||
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3', registrationDeadline: deadline }, COURSE)).toEqual(deadline);
|
||
});
|
||
|
||
test('a cancelled "until" session falls back to the first session', () => {
|
||
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
|
||
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, list)).toEqual(wed(4));
|
||
});
|
||
});
|
||
|
||
describe('late pricing', () => {
|
||
const event = { scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', lateFixedPrice: null };
|
||
|
||
test('not active before the first session starts', () => {
|
||
expect(lateInfo(event, COURSE, wed(3)).active).toBe(false);
|
||
});
|
||
|
||
test('pro-rata on sessions not yet started, rounded to the nearest R10', () => {
|
||
const info = lateInfo(event, COURSE, wed(5)); // session 1 done → 4 of 5 left
|
||
expect(info).toMatchObject({ active: true, sessionsLeft: 4, totalSessions: 5 });
|
||
expect(latePrice(300, info)).toBe(240);
|
||
expect(latePrice(229, info)).toBe(180); // 183.2 → 180
|
||
expect(latePrice(232, info)).toBe(190); // 185.6 → 190
|
||
});
|
||
|
||
test('pro-rata never goes below R10 or above the normal price', () => {
|
||
const info = lateInfo(event, COURSE, wed(26)); // 1 of 5 left
|
||
expect(latePrice(15, info)).toBe(10); // 3 → 0 → at least R10
|
||
expect(latePrice(8, info)).toBe(8); // under R10 already: unchanged
|
||
expect(latePrice(0, info)).toBe(0);
|
||
});
|
||
|
||
test('a session in progress counts as started', () => {
|
||
const info = lateInfo(event, COURSE, new Date(wed(11).getTime() + 60_000));
|
||
expect(info.sessionsLeft).toBe(3);
|
||
});
|
||
|
||
test('fixed late price only ever lowers the price', () => {
|
||
const info = lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: 150 }, COURSE, wed(12));
|
||
expect(latePrice(300, info)).toBe(150);
|
||
expect(latePrice(100, info)).toBe(100);
|
||
});
|
||
|
||
test('full price, late joining off, or a fixed price without an amount → no change', () => {
|
||
expect(lateInfo({ ...event, latePricing: 'full' }, COURSE, wed(12)).active).toBe(false);
|
||
expect(lateInfo({ ...event, lateJoinEnabled: false }, COURSE, wed(12)).active).toBe(false);
|
||
expect(lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: null }, COURSE, wed(12)).active).toBe(false);
|
||
expect(latePrice(300, { active: false })).toBe(300);
|
||
});
|
||
});
|
||
|
||
describe('completionMet', () => {
|
||
test('all sessions (default)', () => {
|
||
expect(completionMet(undefined, 5, 5)).toBe(true);
|
||
expect(completionMet({ type: 'all' }, 4, 5)).toBe(false);
|
||
});
|
||
|
||
test('at least N sessions, capped at the total', () => {
|
||
expect(completionMet({ type: 'count', value: 4 }, 4, 5)).toBe(true);
|
||
expect(completionMet({ type: 'count', value: 4 }, 3, 5)).toBe(false);
|
||
expect(completionMet({ type: 'count', value: 10 }, 3, 3)).toBe(true);
|
||
});
|
||
|
||
test('at least N percent', () => {
|
||
expect(completionMet({ type: 'percent', value: 80 }, 4, 5)).toBe(true);
|
||
expect(completionMet({ type: 'percent', value: 80 }, 3, 5)).toBe(false);
|
||
expect(completionMet({ type: 'percent', value: 66 }, 2, 3)).toBe(true);
|
||
});
|
||
|
||
test('nobody completes without attending', () => {
|
||
expect(completionMet({ type: 'count', value: 1 }, 0, 5)).toBe(false);
|
||
expect(completionMet(undefined, 0, 0)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('settings cleaning', () => {
|
||
test('reminders: off unless an offset and a channel are chosen', () => {
|
||
expect(normalizeReminders(null)).toBeNull();
|
||
expect(normalizeReminders({ offsets: [180], channels: [] })).toBeNull();
|
||
expect(normalizeReminders({ offsets: [180, 999, 1440], channels: ['email', 'sms'] })).toEqual({ offsets: [1440, 180], channels: ['email'] });
|
||
});
|
||
|
||
test('certificates: unknown template falls back to classic, rule is tidied', () => {
|
||
const c = normalizeCertificateConfig({ template: 'fancy', rule: { type: 'count', value: '3' } });
|
||
expect(c.template).toBe('classic');
|
||
expect(c.rule).toEqual({ type: 'count', value: 3 });
|
||
expect(c.title).toBe('Certificate of Completion');
|
||
expect(normalizeCertificateConfig({ rule: { type: 'all', value: 4 } }).rule).toEqual({ type: 'all', value: null });
|
||
});
|
||
});
|
||
|
||
describe('scanSession', () => {
|
||
const event = { checkInWindow: 'whole_day' };
|
||
|
||
test("defaults to today's session", () => {
|
||
expect(scanSession(event, COURSE, null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||
});
|
||
|
||
test('no session today: says when the next one is', () => {
|
||
const r = scanSession(event, COURSE, null, new Date('2026-11-12T09:00:00Z'));
|
||
expect(r.session).toBeNull();
|
||
expect(r.error).toMatch(/next one is Session 3 on 2026-11-18/);
|
||
});
|
||
|
||
test('after the last session: all over', () => {
|
||
expect(scanSession(event, COURSE, null, new Date('2026-12-10T09:00:00Z')).error).toMatch(/over/);
|
||
});
|
||
|
||
test('a picked session must be open now', () => {
|
||
expect(scanSession(event, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||
expect(scanSession(event, COURSE, 's4', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only open on the day/);
|
||
expect(scanSession({ checkInWindow: 'session_only' }, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/2 hours before/);
|
||
});
|
||
|
||
test('a cancelled or unknown session is refused', () => {
|
||
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
|
||
expect(scanSession(event, list, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/cancelled/);
|
||
expect(scanSession(event, COURSE, 'nope').error).toMatch(/isn't part/);
|
||
});
|
||
});
|
||
|
||
describe('redeemedForSession / sessionLabel', () => {
|
||
test("counts only that session's check-ins", () => {
|
||
const usages = [{ sessionId: 's1', quantityRedeemed: 2 }, { sessionId: 's2', quantityRedeemed: 1 }, { sessionId: 's1' }];
|
||
expect(redeemedForSession(usages, 's1')).toBe(3);
|
||
expect(redeemedForSession(usages, 's3')).toBe(0);
|
||
});
|
||
|
||
test('uses the label, else the position among active sessions', () => {
|
||
expect(sessionLabel({ id: 'x', label: 'Opening night' }, COURSE)).toBe('Opening night');
|
||
expect(sessionLabel(COURSE[2], COURSE)).toBe('Session 3');
|
||
const list = [{ ...COURSE[0], cancelledAt: new Date() }, ...COURSE.slice(1)];
|
||
expect(sessionLabel(list[2], list)).toBe('Session 2');
|
||
});
|
||
});
|
||
|
||
describe('pick sessions', () => {
|
||
const pickEvent = {
|
||
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
|
||
lateJoinEnabled: false, sessions: COURSE,
|
||
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
|
||
};
|
||
|
||
test('choosesSessions: pick-mode multi-day events only', () => {
|
||
expect(choosesSessions(pickEvent)).toBe(true);
|
||
expect(choosesSessions({ ...pickEvent, attendanceMode: 'whole' })).toBe(false);
|
||
expect(choosesSessions({ ...pickEvent, scheduleType: 'single' })).toBe(false);
|
||
});
|
||
|
||
test('lineSessions / mergeSessionIds', () => {
|
||
expect(lineSessions({ sessionId: 's2', sessionIds: [] })).toEqual(['s2']);
|
||
expect(lineSessions({ sessionId: null, sessionIds: ['s1', 's3'] })).toEqual(['s1', 's3']);
|
||
expect(lineSessions({ sessionId: null, sessionIds: [] })).toBeNull();
|
||
expect(mergeSessionIds(['s1'], ['s3', 's1'])).toEqual(['s1', 's3']);
|
||
expect(mergeSessionIds(['s1'], [])).toEqual([]);
|
||
});
|
||
|
||
test('an all-sessions ticket with sessions ticked only scans on those', () => {
|
||
const ev = { checkInWindow: 'whole_day' };
|
||
const onDay2 = new Date(wed(11).getTime() + 30 * 60000);
|
||
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s2', 's4']).session.id).toBe('s2');
|
||
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s1', 's4']).error).toMatch(/isn't booked for Session 2/);
|
||
expect(ticketScanSession(ev, COURSE, null, null, onDay2, []).session.id).toBe('s2');
|
||
});
|
||
|
||
test('ticket dates list the ticked sessions', () => {
|
||
const ev = { ...pickEvent, sessions: COURSE };
|
||
expect(ticketDateText(ev, 'short', null, null, ['s2', 's4'])).toBe('2 sessions · 11 Nov, 25 Nov');
|
||
expect(ticketDateText(ev, 'long', null, null, ['s2'])).toBe('Wednesday, 11 November 2026 (Session 2)');
|
||
});
|
||
|
||
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
|
||
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
|
||
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
|
||
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
|
||
});
|
||
|
||
test('a pick line becomes one line per session; an all-sessions line keeps its ticked sessions', () => {
|
||
const out = expandPickLines(pickEvent, [
|
||
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
|
||
{ eventOptionId: 'full', quantity: 1 },
|
||
{ eventOptionId: 'full', quantity: 1, sessionIds: ['s2', 's5'] },
|
||
], { now: wed(5) });
|
||
expect(out).toEqual([
|
||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
|
||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
|
||
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: [] },
|
||
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: ['s2', 's5'] },
|
||
]);
|
||
});
|
||
|
||
test('sessions must be chosen, upcoming, and only on events where people choose them', () => {
|
||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
|
||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
|
||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
|
||
expect(() => expandPickLines({ ...pickEvent, attendanceMode: 'whole' }, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
|
||
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
|
||
});
|
||
|
||
test('staff can still book a session that is in progress', () => {
|
||
const during = new Date(wed(4).getTime() + 30 * 60000);
|
||
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
|
||
});
|
||
|
||
test('registration stays open until the last session; whole-course tickets close at the first', () => {
|
||
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
|
||
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
|
||
});
|
||
|
||
test('a picked ticket only checks in on its own session', () => {
|
||
const event = { checkInWindow: 'whole_day' };
|
||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
|
||
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
|
||
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
|
||
});
|
||
|
||
test('groupTickets puts one booking\'s picked tickets together', () => {
|
||
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
|
||
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
|
||
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
|
||
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
|
||
expect(groups[1].sessionIds).toBeNull();
|
||
});
|
||
});
|