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>
271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
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');
|
||
});
|
||
});
|