[pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring

- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
  display, settings modal with CalDAV/ICS source management and a
  Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
  never in responses), aggregated events across sources with per-source
  error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
  RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
  EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
  node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
  a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
  verified byte-for-byte against openssl passwd -apr1 vectors), stores
  credentials, registers the source. Deploy hook in routes/apps/deploy.js
  wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
  route tests). Full suite 3552 passed; only pre-existing pdf-parse
  billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
This commit is contained in:
Hermes committed 2026-10-07 19:57:22 -07:00
1 parent 448f5358bf
commit 74e21c1edc
18 files changed
+1845

No files matched your search

@@ -0,0 +1,122 @@
/**
* 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();
});
});