/** * DC-161: ICS parser unit tests. * Covers: basic VEVENT extraction, all-day events, RRULE expansion * (DAILY/WEEKLY-BYDAY/MONTHLY + COUNT/UNTIL/INTERVAL), EXDATE, * RECURRENCE-ID overrides, event cap, malformed DTSTART. */ const { parseIcs, parseIcsDate, unfoldLines } = require('../../src/calendar/ics-parser'); const RANGE = { start: new Date('2026-10-01T00:00:00Z'), end: new Date('2026-11-01T00:00:00Z') }; function ics(body) { return `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n${body}\r\nEND:VCALENDAR`; } describe('DC-161 ics-parser', () => { test('parses a simple timed event', () => { const { events, errors } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:1\r\nDTSTART:20261007T140000Z\r\nDTEND:20261007T150000Z\r\nSUMMARY:Dentist\\, cleaning\r\nLOCATION:Main St\r\nEND:VEVENT' ), RANGE); expect(errors).toEqual([]); expect(events).toHaveLength(1); expect(events[0].title).toBe('Dentist, cleaning'); expect(events[0].start.toISOString()).toBe('2026-10-07T14:00:00.000Z'); expect(events[0].allDay).toBe(false); expect(events[0].location).toBe('Main St'); }); test('parses an all-day event (DATE value)', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:2\r\nDTSTART;VALUE=DATE:20261010\r\nSUMMARY:Conference\r\nEND:VEVENT' ), RANGE); expect(events[0].allDay).toBe(true); expect(events[0].start.getFullYear()).toBe(2026); expect(events[0].start.getMonth()).toBe(9); expect(events[0].start.getDate()).toBe(10); }); test('daily recurrence with COUNT expands within window', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:3\r\nDTSTART:20261001T090000Z\r\nDTEND:20261001T093000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Standup\r\nEND:VEVENT' ), RANGE); expect(events).toHaveLength(5); expect(events[0].start.getUTCDate()).toBe(1); expect(events[4].start.getUTCDate()).toBe(5); expect(events.every(e => e.recurring)).toBe(true); }); test('weekly BYDAY expansion lands on correct weekdays', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:4\r\nDTSTART:20261005T100000Z\r\nDTEND:20261005T110000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\nSUMMARY:Gym\r\nEND:VEVENT' ), RANGE); // Oct 5 2026 is a Monday. Expect 3/wk for the 4 weeks in October window. expect(events.length).toBeGreaterThanOrEqual(11); for (const ev of events) { expect([1, 3, 5]).toContain(ev.start.getUTCDay()); } }); test('EXDATE excludes occurrences', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:5\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;COUNT=4\r\nEXDATE:20261003T090000Z\r\nSUMMARY:Except Wednesday\r\nEND:VEVENT' ), RANGE); expect(events).toHaveLength(3); expect(events.some(e => e.start.getUTCDate() === 3)).toBe(false); }); test('UNTIL bounds recurrence', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:6\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;UNTIL=20261004T090000Z\r\nSUMMARY:Bounded\r\nEND:VEVENT' ), RANGE); expect(events).toHaveLength(4); }); test('events outside window are excluded; window straddles inclusion', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:7\r\nDTSTART:20260901T090000Z\r\nDTEND:20260901T100000Z\r\nSUMMARY:September only\r\nEND:VEVENT' ), RANGE); expect(events).toHaveLength(0); }); test('RECURRENCE-ID override replaces instance title', () => { const { events } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:8\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Original\r\nEND:VEVENT\r\n' + 'BEGIN:VEVENT\r\nUID:8\r\nRECURRENCE-ID:20261002T090000Z\r\nDTSTART:20261002T130000Z\r\nSUMMARY:Moved to 1pm\r\nEND:VEVENT' ), RANGE); const moved = events.find(e => e.start.getUTCDate() === 2); expect(moved.title).toBe('Moved to 1pm'); expect(moved.start.getUTCHours()).toBe(13); }); test('malformed DTSTART is reported as an error, not thrown', () => { const { events, errors } = parseIcs(ics( 'BEGIN:VEVENT\r\nUID:9\r\nDTSTART:notadate\r\nSUMMARY:Broken\r\nEND:VEVENT' ), RANGE); expect(events).toHaveLength(0); expect(errors.length).toBeGreaterThanOrEqual(1); }); test('event count is capped at 500', () => { let body = ''; for (let i = 0; i < 600; i++) { body += `BEGIN:VEVENT\r\nUID:x${i}\r\nDTSTART:20261001T${String(i % 24).padStart(2, '0')}0000Z\r\nSUMMARY:E${i}\r\nEND:VEVENT\r\n`; } const { events } = parseIcs(ics(body), RANGE); expect(events).toHaveLength(500); }); test('unfoldLines joins folded lines (RFC: CRLF + one WSP removed)', () => { // Two leading spaces: fold char removed, second space is content expect(unfoldLines('SUMMARY:hello\r\n world')).toEqual(['SUMMARY:hello world']); // Exactly one space: fully joined expect(unfoldLines('SUMMARY:hello\r\n world')).toEqual(['SUMMARY:helloworld']); }); test('parseIcsDate handles UTC, floating, and date forms', () => { expect(parseIcsDate('20261007T140000Z').toISOString()).toBe('2026-10-07T14:00:00.000Z'); expect(parseIcsDate('20261007')).toEqual(new Date(2026, 9, 7)); expect(parseIcsDate('20261007T140000')).toEqual(new Date(2026, 9, 7, 14, 0, 0)); expect(parseIcsDate('garbage')).toBeNull(); }); });