import { test } from 'node:test'; import assert from 'node:assert/strict'; import { unescapeText, unfold, parseLine, parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences, toLocalDate } from '../server/ical.js'; test('unfold und parseLine', () => { const lines = unfold('SUMMARY:Ein sehr\r\n langer Titel\r\nDTSTART;TZID=Europe/Berlin:20261005T093000\r\n'); assert.deepEqual(lines, ['SUMMARY:Ein sehr langer Titel', 'DTSTART;TZID=Europe/Berlin:20261005T093000']); const p = parseLine('ATTENDEE;CN="Max, Mustermann";ROLE=REQ-PARTICIPANT:mailto:max@example.de'); assert.equal(p.name, 'ATTENDEE'); assert.equal(p.params.CN, 'Max, Mustermann'); assert.equal(p.value, 'mailto:max@example.de'); }); test('unescapeText löst \\; \\, \\n und \\\\ auf', () => { assert.equal(unescapeText('a\\;b\\,c\\nd\\\\e'), 'a;b,c\nd\\e'); assert.equal(unescapeText('x\\\\ny'), 'x\\ny', 'maskierter Backslash gefolgt von n ist kein Zeilenumbruch'); }); test('toLocalDate: Datum, UTC nach Berlin, Wandzeit', () => { assert.deepEqual(toLocalDate('20261005', { VALUE: 'DATE' }).date, '2026-10-05'); assert.equal(toLocalDate('20261005T220000Z').date, '2026-10-06'); // 22:00 UTC = 00:00 MESZ am Folgetag assert.equal(toLocalDate('20261205T230000Z').date, '2026-12-06'); // Winterzeit assert.equal(toLocalDate('20261005T093000', { TZID: 'Europe/Berlin' }).date, '2026-10-05'); assert.equal(toLocalDate('kaputt'), null); }); const ICS = (body) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`; test('ganztägige Urlaubswoche: DTEND exklusiv', () => { const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:a\r\nSUMMARY:Stefan Frühmorgen (Urlaub)\r\nDTSTART;VALUE=DATE:20260914\r\nDTEND;VALUE=DATE:20260919\r\nEND:VEVENT')); assert.equal(ev.from, '2026-09-14'); assert.equal(ev.to, '2026-09-18'); assert.equal(ev.allDay, true); assert.equal(ev.summary, 'Stefan Frühmorgen (Urlaub)'); }); test('Termin mit Uhrzeit, Termin bis Mitternacht, DURATION, mehrere VEVENTs', () => { const evs = parseEvents(ICS([ 'BEGIN:VEVENT\r\nUID:b\r\nSUMMARY:Libor Herudek (Urlaub)\r\nDTSTART;TZID=Europe/Berlin:20260918T120000\r\nDTEND;TZID=Europe/Berlin:20260918T160000\r\nEND:VEVENT', 'BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:X (krank)\r\nDTSTART:20260917T220000Z\r\nDTEND:20260918T220000Z\r\nEND:VEVENT', 'BEGIN:VEVENT\r\nUID:d\r\nSUMMARY:Y (frei)\r\nDTSTART;VALUE=DATE:20260921\r\nDURATION:P2D\r\nEND:VEVENT', 'BEGIN:VEVENT\r\nUID:e\r\nSUMMARY:Z\r\nDTSTART;VALUE=DATE:20260922\r\nEND:VEVENT', ].join('\r\n'))); assert.equal(evs.length, 4); assert.deepEqual([evs[0].from, evs[0].to], ['2026-09-18', '2026-09-18']); assert.deepEqual([evs[1].from, evs[1].to], ['2026-09-18', '2026-09-18']); // 00:00–24:00 MESZ assert.deepEqual([evs[2].from, evs[2].to], ['2026-09-21', '2026-09-22']); assert.deepEqual([evs[3].from, evs[3].to], ['2026-09-22', '2026-09-22']); }); test('RRULE und RECURRENCE-ID werden durchgereicht, Alarme stören nicht', () => { const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:r\r\nSUMMARY:A (frei)\r\nDTSTART;VALUE=DATE:20260914\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT')); assert.equal(ev.rrule, 'FREQ=WEEKLY;BYDAY=MO'); assert.equal(ev.description, ''); }); test('vCard 3.0 mit Base64-Foto und vCard 4.0 mit data-URI', () => { const v3 = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ferdinand\r\nFN:Ferdinand Urban\r\nN:Urban;Ferdinand;;;\r\nEMAIL;TYPE=INTERNET:urban@team-tradeo.de\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n SkZJRg==\r\nCLOUD:ferdinand@cld2.tradeo.de\r\nEND:VCARD\r\n'; const [c] = parseVCards(v3); assert.equal(c.uid, 'ferdinand'); assert.equal(c.firstName, 'Ferdinand'); assert.equal(c.lastName, 'Urban'); assert.equal(c.email, 'urban@team-tradeo.de'); assert.deepEqual(c.photo, { kind: 'inline', mime: 'image/jpeg', base64: '/9j/4AAQSkZJRg==' }); const v4 = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:Anna Maria Ciesla\r\nPHOTO:data:image/png;base64,iVBORw0KGgo=\r\nEND:VCARD\r\n'; const [d] = parseVCards(v4); assert.equal(d.firstName, 'Anna Maria'); assert.equal(d.lastName, 'Ciesla'); assert.equal(d.photo.mime, 'image/png'); assert.equal(d.photo.base64, 'iVBORw0KGgo='); const v4url = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:y\r\nFN:Solo\r\nPHOTO;VALUE=URI:https://cloud.example.de/avatar.png\r\nEND:VCARD\r\n'; const [e] = parseVCards(v4url); assert.equal(e.lastName, 'Solo'); assert.equal(e.firstName, ''); assert.deepEqual(e.photo, { kind: 'url', url: 'https://cloud.example.de/avatar.png' }); }); test('Abwesenheitstitel deuten', () => { assert.deepEqual(parseAbsenceSummary('Stefan Frühmorgen (Urlaub)'), { name: 'Stefan Frühmorgen', kind: 'urlaub', raw: 'Urlaub' }); assert.equal(parseAbsenceSummary('Carmine Mastromarino (krank)').kind, 'krankheit'); assert.equal(parseAbsenceSummary('Alex Koop (frei)').kind, 'frei'); assert.equal(parseAbsenceSummary('Hans Holzmann [Home Office]').kind, 'frei'); assert.deepEqual(parseAbsenceSummary('Nur Name'), { name: 'Nur Name', kind: 'sonstiges', raw: '' }); }); test('Namen zuordnen: Vor+Nach, Nach+Vor, Umlaute, eindeutiger Nachname', () => { const persons = [ { id: '1', firstName: 'Stefan', lastName: 'Frühmorgen' }, { id: '2', firstName: 'Michael', lastName: 'Lotter' }, { id: '3', firstName: 'Michael', lastName: 'Schuster' }, ]; assert.equal(matchPerson('Stefan Frühmorgen', persons).id, '1'); assert.equal(matchPerson('Fruehmorgen, Stefan', persons).id, '1'); assert.equal(matchPerson('FRÜHMORGEN STEFAN', persons).id, '1'); assert.equal(matchPerson('Stefan Fru\u0308hmorgen', persons).id, '1', 'NFD-Umlaut (u + Trema)'); assert.equal(matchPerson('Lotter', persons).id, '2'); assert.equal(matchPerson('Michael', persons), null); assert.equal(matchPerson('Unbekannt Person', persons), null); assert.equal(matchPerson('', persons), null); }); test('Abwesenheiten verschmelzen: angrenzend und überlappend, getrennt nach Art', () => { const merged = mergeAbsences([ { personId: 'a', type: 'urlaub', from: '2026-10-05', to: '2026-10-06' }, { personId: 'a', type: 'urlaub', from: '2026-10-07', to: '2026-10-09' }, { personId: 'a', type: 'urlaub', from: '2026-10-08', to: '2026-10-08' }, { personId: 'a', type: 'frei', from: '2026-10-05', to: '2026-10-05' }, { personId: 'a', type: 'urlaub', from: '2026-10-19', to: '2026-10-19' }, { personId: 'b', type: 'urlaub', from: '2026-10-05', to: '2026-10-05' }, ]); assert.deepEqual(merged.map((m) => `${m.personId}/${m.type}/${m.from}/${m.to}`), [ 'a/urlaub/2026-10-05/2026-10-09', 'a/frei/2026-10-05/2026-10-05', 'b/urlaub/2026-10-05/2026-10-05', 'a/urlaub/2026-10-19/2026-10-19', ]); });