All three r2 blocking issues fixed + polish applied:
- useExisting (adopted) Radicale containers no longer get a source registered
with credentials the operator never provisioned. Registration happens ONLY
when pre-start provisioning succeeded; adopted deployments get a
calendarHint in the deploy response pointing at the manual-wiring modal.
- Weekly BYDAY expansion: candidates earlier than DTSTART are skipped (they
do not consume COUNT); window inclusion now uses interval-overlap
semantics (occurrence span vs window span) for both recurring and
non-recurring events. Three new tests cover midweek DTSTART, a straddling
multi-day event, and a boundary-exactly-ending event.
- Verifier is now read-only on the graded tree: no stash/checkout/rewrite.
Base proof runs in a temporary git worktree of 448f535 under /tmp (removed
on exit); the flaky pdf-parse suite is proven pre-existing via "red at
base" + "patch touches no billing file" (empty diff), and a race-flaky
suite (DC-116, witnessed once under parallel load) is given one isolated
rerun which must pass.
Polish: PUT /sources normalizes the user field (and clamps name) like POST.
Calendar suites: 38 passed (35 + 3 new recurrence tests). The 3558/3564
full-suite numbers vary by one flaky pdf-parse member between runs; the
evidence now asserts the invariant (suite red at base, no billing file in
the patch, flaky suites green on isolated rerun) instead of an exact count.
149 lines
6.5 KiB
JavaScript
149 lines
6.5 KiB
JavaScript
/**
|
|
* 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('weekly BYDAY does not emit candidates before DTSTART (midweek start)', () => {
|
|
// 2026-10-07 is a Wednesday; a MO,WE,FR rule must NOT emit Monday Oct 5.
|
|
const { events } = parseIcs(ics(
|
|
'BEGIN:VEVENT\r\nUID:10\r\nDTSTART:20261007T100000Z\r\nDTEND:20261007T110000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=3\r\nSUMMARY:Midweek start\r\nEND:VEVENT'
|
|
), RANGE);
|
|
expect(events.map(e => e.start.toISOString().slice(0, 10))).toEqual([
|
|
'2026-10-07', '2026-10-09', '2026-10-12',
|
|
]);
|
|
});
|
|
|
|
test('multi-day event straddling the window start is included (overlap semantics)', () => {
|
|
// Event runs Sep 28 → Oct 2; window starts Oct 1.
|
|
const { events } = parseIcs(ics(
|
|
'BEGIN:VEVENT\r\nUID:11\r\nDTSTART:20260928T000000Z\r\nDTEND:20261002T000000Z\r\nSUMMARY:Straddling retreat\r\nEND:VEVENT'
|
|
), RANGE);
|
|
expect(events).toHaveLength(1);
|
|
});
|
|
|
|
test('multi-day event ending exactly at window start is excluded', () => {
|
|
// Event runs Sep 25 → Oct 1 00:00; window starts Oct 1 00:00 — zero overlap.
|
|
const { events } = parseIcs(ics(
|
|
'BEGIN:VEVENT\r\nUID:12\r\nDTSTART:20260925T000000Z\r\nDTEND:20261001T000000Z\r\nSUMMARY:Ends at boundary\r\nEND:VEVENT'
|
|
), RANGE);
|
|
expect(events).toHaveLength(0);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|