Files
hope-events/backend/tests/sessions.test.js
T
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- 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>
2026-10-23 11:38:21 +02:00

370 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});