Youth events need a name and date of birth for every attendee, and a parent/guardian's details for under-18s. Parents get an email and WhatsApp link to a consent page that records the signed name, time, IP, device and exact consent text; medical and emergency details are stored encrypted. Tickets are issued but check-in is blocked until consent is given (supervisor override audited). A daily job sends reminders and purges medical details after the retention period. Adds a supervisor consent tracker, consent status and medical list reports, and clears consent data when an account is anonymised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
6.3 KiB
JavaScript
136 lines
6.3 KiB
JavaScript
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/);
|
|
});
|
|
});
|