Files
hope-events/backend/tests/sessions.test.js
T
joshuaandClaude Opus 5.5 c79b74bc67 Add multi-day event sessions (backend)
Events can run over several sessions: per-session check-in with a whole-day
or session-time window, late joining with pro-rata or fixed late pricing,
an attendance register (Excel/PDF), session reminders, and completion
certificates (four templates, public verify page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:20:52 +02:00

271 lines
12 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,
} = 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');
});
});