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(); }); });