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>
105 lines
5.0 KiB
JavaScript
105 lines
5.0 KiB
JavaScript
const { validateAttendees, planLineTickets, lineKey } = require('../src/utils/attendees');
|
|
|
|
const EVENT = {
|
|
allowNamedAttendees: true,
|
|
eventOptions: [
|
|
{ id: 'kid', name: 'Camper', isMainTicket: true },
|
|
{ id: 'shirt', name: 'T-shirt', isMainTicket: false },
|
|
],
|
|
};
|
|
const places = (n) => ({ [lineKey('kid', null)]: n });
|
|
|
|
describe('validateAttendees', () => {
|
|
test('cleans up names and contact details', () => {
|
|
const out = validateAttendees(EVENT, places(2), [
|
|
{ eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' },
|
|
{ eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' },
|
|
]);
|
|
expect(out).toMatchObject([
|
|
{ eventOptionId: 'kid', variantId: null, name: 'Sam', email: null, phone: '0821234567' },
|
|
{ eventOptionId: 'kid', variantId: null, name: 'Alex', email: 'alex@example.com', phone: null },
|
|
]);
|
|
});
|
|
|
|
test('nothing to check when no attendees are given', () => {
|
|
expect(validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [])).toEqual([]);
|
|
});
|
|
|
|
test('refuses names on events that do not take them', () => {
|
|
expect(() => validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [{ eventOptionId: 'kid', name: 'Sam' }])).toThrow(/doesn't take attendee names/);
|
|
});
|
|
|
|
test('only main tickets can be named', () => {
|
|
expect(() => validateAttendees(EVENT, { [lineKey('shirt', null)]: 1 }, [{ eventOptionId: 'shirt', name: 'Sam' }])).toThrow(/main tickets/);
|
|
});
|
|
|
|
test('needs a name and a valid email', () => {
|
|
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: ' ' }])).toThrow(/enter a name/);
|
|
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'Sam', email: 'nope' }])).toThrow(/valid email/);
|
|
});
|
|
|
|
test('no more names than places, counting names already given', () => {
|
|
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/More names/);
|
|
expect(() => validateAttendees(EVENT, places(3), [{ eventOptionId: 'kid', name: 'C' }], places(2))).not.toThrow();
|
|
expect(() => validateAttendees(EVENT, places(2), [{ eventOptionId: 'kid', name: 'C' }], places(2))).toThrow(/More names/);
|
|
});
|
|
});
|
|
|
|
describe('planLineTickets', () => {
|
|
const scan = (q = 1) => [{ quantityRedeemed: q }];
|
|
|
|
test('no attendees: one shared ticket for the whole quantity', () => {
|
|
expect(planLineTickets({ quantity: 3 }, [], [])).toEqual({ create: [{ attendeeId: null, holderUserId: null, quantity: 3 }], update: [], remove: [] });
|
|
});
|
|
|
|
test('every attendee gets their own ticket; unnamed places share one', () => {
|
|
const plan = planLineTickets({ quantity: 3 }, [{ id: 'a1', userId: 'u9' }, { id: 'a2', userId: null }], []);
|
|
expect(plan.create).toEqual([
|
|
{ attendeeId: 'a1', holderUserId: 'u9', quantity: 1 },
|
|
{ attendeeId: 'a2', holderUserId: null, quantity: 1 },
|
|
{ attendeeId: null, holderUserId: null, quantity: 1 },
|
|
]);
|
|
});
|
|
|
|
test('naming every place replaces the shared ticket', () => {
|
|
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: [] }]);
|
|
expect(plan.remove).toEqual(['t0']);
|
|
expect(plan.create).toHaveLength(2);
|
|
});
|
|
|
|
test('a scanned shared ticket is kept and never shrunk below what was used', () => {
|
|
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: scan(1) }]);
|
|
expect(plan.remove).toEqual([]);
|
|
expect(plan.update).toEqual([{ id: 't0', data: { quantity: 1 } }]);
|
|
});
|
|
|
|
test('removing an attendee removes their unscanned ticket', () => {
|
|
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1' }], [
|
|
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
|
|
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: [] },
|
|
]);
|
|
expect(plan).toEqual({ create: [], update: [], remove: ['t2'] });
|
|
});
|
|
|
|
test("keeps a scanned ticket whose attendee was removed, and counts it as a used place", () => {
|
|
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }], [
|
|
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
|
|
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: scan() },
|
|
]);
|
|
expect(plan).toEqual({ create: [], update: [], remove: [] });
|
|
});
|
|
|
|
test('follows the attendee to a newly linked account', () => {
|
|
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1', userId: 'u2' }], [{ id: 't1', attendeeId: 'a1', quantity: 1, holderUserId: null, usages: [] }]);
|
|
expect(plan.update).toEqual([{ id: 't1', data: { holderUserId: 'u2', quantity: 1 } }]);
|
|
});
|
|
|
|
test('existing single shared ticket is resized, duplicates dropped', () => {
|
|
const plan = planLineTickets({ quantity: 4 }, [], [
|
|
{ id: 't1', attendeeId: null, quantity: 2, usages: [] },
|
|
{ id: 't2', attendeeId: null, quantity: 2, usages: [] },
|
|
]);
|
|
expect(plan).toEqual({ create: [], update: [{ id: 't1', data: { quantity: 4 } }], remove: ['t2'] });
|
|
});
|
|
});
|