process.env.JWT_SECRET = 'test-secret'; const { ageOn, isMinor, consentStatusFor, reminderDue, validateConsentAnswer, hashToken } = require('../src/utils/consent'); const { validateAttendees, lineKey } = require('../src/utils/attendees'); const START = new Date('2026-12-10T08:00:00Z'); const DAY = 24 * 60 * 60 * 1000; describe('ageOn / isMinor', () => { test('counts whole years, turning a year older on the birthday', () => { expect(ageOn('2008-12-10', START)).toBe(18); expect(ageOn('2008-12-11', START)).toBe(17); expect(ageOn('2010-01-01', START)).toBe(16); }); test('under 18 on the event start date', () => { expect(isMinor('2008-12-11', START)).toBe(true); expect(isMinor('2008-12-10', START)).toBe(false); expect(isMinor(null, START)).toBe(false); }); }); describe('consentStatusFor', () => { const event = { requiresParentalConsent: true, startDate: START }; const kid = { dateOfBirth: '2012-05-01' }; test('only needed on youth events, for minors', () => { expect(consentStatusFor(kid, { ...event, requiresParentalConsent: false }, null)).toBe('not_required'); expect(consentStatusFor({ dateOfBirth: '1990-01-01' }, event, null)).toBe('not_required'); }); test('a minor with no request, or an unnamed ticket, is missing consent', () => { expect(consentStatusFor(kid, event, null)).toBe('missing'); expect(consentStatusFor(null, event, null)).toBe('missing'); }); test('otherwise the latest request decides', () => { expect(consentStatusFor(kid, event, { status: 'pending' })).toBe('pending'); expect(consentStatusFor(kid, event, { status: 'given' })).toBe('given'); expect(consentStatusFor(kid, event, { status: 'declined' })).toBe('declined'); }); }); describe('reminderDue', () => { const now = new Date('2026-12-01T10:00:00Z'); const req = (over) => ({ status: 'pending', remindersSent: 0, lastSentAt: new Date(now.getTime() - 1 * DAY), expiresAt: START, ...over }); test('first reminder 2 days after the request', () => { expect(reminderDue(req({}), now)).toBe(false); expect(reminderDue(req({ lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(true); }); test('then every 3 days', () => { expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(false); expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 3 * DAY) }), now)).toBe(true); }); test('never once answered or expired', () => { expect(reminderDue(req({ status: 'given', lastSentAt: new Date(0) }), now)).toBe(false); expect(reminderDue(req({ lastSentAt: new Date(0), expiresAt: new Date(now.getTime() - 1) }), now)).toBe(false); }); }); describe('validateConsentAnswer', () => { const event = { consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true }; const ok = { decision: 'give', signedName: 'Pat Parent', isGuardian: true, medical: { allergies: 'Peanuts' }, emergency: { name: 'Gran', phone: '0821234567' }, photoConsent: false }; test('accepts a complete answer and keeps only known fields', () => { const out = validateConsentAnswer({ ...ok, extra: 'x' }, event); expect(out).toEqual({ decision: 'give', signedName: 'Pat Parent', medical: { allergies: 'Peanuts', medication: '', conditions: '', medicalAid: '' }, emergency: { name: 'Gran', phone: '0821234567', relationship: '' }, photoConsent: false, }); }); test('needs a decision, a typed name and the guardian tick', () => { expect(() => validateConsentAnswer({ ...ok, decision: 'maybe' }, event)).toThrow(/give or decline/); expect(() => validateConsentAnswer({ ...ok, signedName: ' ' }, event)).toThrow(/full name/); expect(() => validateConsentAnswer({ ...ok, isGuardian: false }, event)).toThrow(/parent or legal guardian/); }); test('needs an emergency contact and a photo answer when asked for', () => { expect(() => validateConsentAnswer({ ...ok, emergency: {} }, event)).toThrow(/emergency contact/); expect(() => validateConsentAnswer({ ...ok, photoConsent: undefined }, event)).toThrow(/photo/); }); test('declining needs no extra details', () => { expect(validateConsentAnswer({ decision: 'decline', signedName: 'Pat', isGuardian: true }, event)) .toEqual({ decision: 'decline', signedName: 'Pat', medical: null, emergency: null, photoConsent: null }); }); }); describe('hashToken', () => { test('is stable and never the token itself', () => { expect(hashToken('abc')).toBe(hashToken('abc')); expect(hashToken('abc')).not.toContain('abc'); expect(hashToken('abc')).not.toBe(hashToken('abd')); }); }); describe('validateAttendees on a youth event', () => { const event = { requiresParentalConsent: true, allowNamedAttendees: true, startDate: START, eventOptions: [{ id: 'kid', name: 'Camper', isMainTicket: true }, { id: 'shirt', name: 'T-shirt', isMainTicket: false }], }; const places = { [lineKey('kid', null)]: 2, [lineKey('shirt', null)]: 1 }; const parent = { parentName: 'Pat', parentEmail: 'pat@example.com', parentPhone: '0821234567' }; test('every main place must be named', () => { expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '1990-01-01' }])).toThrow(/every "Camper" ticket/); }); test('a date of birth is required', () => { expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/date of birth/); }); test('minors need full parent details; adults do not', () => { expect(() => validateAttendees(event, places, [ { eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', parentName: 'Pat' }, { eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' }, ])).toThrow(/under 18/); const out = validateAttendees(event, places, [ { eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', ...parent }, { eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' }, ]); expect(out[0]).toMatchObject({ name: 'Kid', parentEmail: 'pat@example.com' }); expect(out[0].dateOfBirth).toBeInstanceOf(Date); }); test('a date of birth in the future is refused', () => { expect(() => validateAttendees(event, { [lineKey('kid', null)]: 1 }, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '2999-01-01' }])).toThrow(/valid date of birth/); }); });