Files
eleith 12a4db3303
ci/woodpecker/pr/calendar Pipeline was successful
ci/woodpecker/pr/cli Pipeline was successful
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/pr/worker Pipeline was successful
fix(calendar): preserve Unicode text in expanded calendar fields
2026-10-26 07:23:02 -07:00

549 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, vi } from 'vitest';
import { parseExpandedCalDavEvents } from './parse.js';
const single = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:event-1@test
DTSTAMP:20260101T000000Z
DTSTART:20260415T140000Z
DTEND:20260415T150000Z
SUMMARY:Lunch
END:VEVENT
END:VCALENDAR`;
const withDuration = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:event-2@test
DTSTAMP:20260101T000000Z
DTSTART:20260420T090000Z
DURATION:PT45M
SUMMARY:Standup
END:VEVENT
END:VCALENDAR`;
const overrideOccurrence = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:weekly-1@test
DTSTAMP:20260101T000000Z
DTSTART:20260113T140000Z
DTEND:20260113T150000Z
RECURRENCE-ID:20260113T130000Z
SUMMARY:Weekly sync (moved)
END:VEVENT
END:VCALENDAR`;
const multipleEvents = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:a@test
DTSTAMP:20260101T000000Z
DTSTART:20260415T140000Z
DTEND:20260415T150000Z
SUMMARY:A
END:VEVENT
BEGIN:VEVENT
UID:b@test
DTSTAMP:20260101T000000Z
DTSTART:20260416T090000Z
DTEND:20260416T093000Z
SUMMARY:B
END:VEVENT
END:VCALENDAR`;
test('parses a single VEVENT with DTSTART/DTEND', () => {
const busy = parseExpandedCalDavEvents(single);
expect(busy).toHaveLength(1);
expect(busy[0].uid).toBe('event-1@test');
expect(busy[0].start.toString()).toBe('2026-04-15T14:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-15T15:00:00Z');
});
test.each([
['a folded property name', 'DTSTA\r\n RT:20260415T140000Z'],
['a folded value', 'DTSTART:20260415T14\r\n\t0000Z'],
['quoted parameter delimiters', 'DTSTART;X-NOTE="a:b;VALUE=DATE,c":20260415T140000Z']
])('reads %s without changing event bounds', (_name, start) => {
const source = single.replace('DTSTART:20260415T140000Z', start);
const busy = parseExpandedCalDavEvents(source);
expect(busy).toHaveLength(1);
expect(busy[0].start.toString()).toBe('2026-04-15T14:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-15T15:00:00Z');
});
test('unfolds a DATE parameter header without falling back to DATE-TIME', () => {
const source = single
.replace('DTSTART:20260415T140000Z', 'DTSTART;VA\r\n LUE="DATE":20260415')
.replace('DTEND:20260415T150000Z', 'DTEND;VALUE=DATE:20260416');
const busy = parseExpandedCalDavEvents(source);
expect(busy).toHaveLength(1);
expect(busy[0].start.toString()).toBe('2026-04-15T00:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-16T00:00:00Z');
});
test('rejects duplicate parameters even when their case differs and values agree', () => {
const source = single.replace('DTSTART:', 'DTSTART;VALUE=DATE-TIME;value=DATE-TIME:');
expect(() => parseExpandedCalDavEvents(source)).toThrow(/Duplicate CalDAV property parameter/);
});
test('rejects an unterminated parameter quote rather than guessing the value boundary', () => {
const source = single.replace('DTSTART:', 'DTSTART;VALUE="DATE-TIME:');
expect(() => parseExpandedCalDavEvents(source)).toThrow(/invalid content line/);
});
test('unfolds UID text without unescaping or changing its opaque identity', () => {
const source = single.replace('event-1@test', 'Team\\,A\\;\r\n B\\\\C@Test');
const busy = parseExpandedCalDavEvents(source);
expect(busy).toHaveLength(1);
expect(busy[0].uid).toBe('Team\\,A\\;B\\\\C@Test');
});
test.each([
['line separator', '\u2028'],
['paragraph separator', '\u2029']
])('accepts a Unicode %s in description text without losing the event', (_name, separator) => {
const source = withDuration.replace(
'DURATION:PT45M',
`DESCRIPTION:First${separator}second\nDURATION:PT45M`
);
const busy = parseExpandedCalDavEvents(source);
expect(busy).toHaveLength(1);
expect(busy[0].start.toString()).toBe('2026-04-20T09:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-20T09:45:00Z');
});
test('parses VEVENT with DURATION instead of DTEND', () => {
const busy = parseExpandedCalDavEvents(withDuration);
expect(busy).toHaveLength(1);
expect(busy[0].start.toString()).toBe('2026-04-20T09:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-20T09:45:00Z');
});
test.each([
'RRULE:FREQ=WEEKLY;COUNT=4',
'RDATE:20260416T140000Z',
'EXDATE:20260415T140000Z',
'EXRULE:FREQ=DAILY'
])('rejects an unexpanded %s instead of importing only the master', (property) => {
const unexpanded = single.replace('SUMMARY:Lunch', `${property}\nSUMMARY:Lunch`);
expect(() => parseExpandedCalDavEvents(unexpanded)).toThrow(
/did not return expanded occurrences/
);
});
test('rejects unresolved timezone references and definitions', () => {
const zoned = single.replace(
'DTSTART:20260415T140000Z',
'DTSTART;TZID=America/New_York:20260415T100000'
);
const withZone = single.replace(
'BEGIN:VEVENT',
'BEGIN:VTIMEZONE\nTZID:America/New_York\nEND:VTIMEZONE\nBEGIN:VEVENT'
);
expect(() => parseExpandedCalDavEvents(zoned)).toThrow(/did not return expanded occurrences/);
expect(() => parseExpandedCalDavEvents(withZone)).toThrow(/did not return expanded occurrences/);
});
test('uses a moved occurrence’s actual bounds, not its RECURRENCE-ID', () => {
const busy = parseExpandedCalDavEvents(overrideOccurrence);
expect(busy).toHaveLength(1);
expect(busy[0].start.toString()).toBe('2026-01-13T14:00:00Z');
expect(busy[0].end.toString()).toBe('2026-01-13T15:00:00Z');
});
test('parses multiple VEVENTs preserving order', () => {
const busy = parseExpandedCalDavEvents(multipleEvents);
expect(busy).toHaveLength(2);
expect(busy[0].uid).toBe('a@test');
expect(busy[1].uid).toBe('b@test');
});
test('rejects malformed calendar data rather than reporting an empty calendar', () => {
expect(() => parseExpandedCalDavEvents('not an ics calendar')).toThrow(
/Invalid CalDAV calendar data/
);
expect(() => parseExpandedCalDavEvents(single.replace('END:VEVENT\n', ''))).toThrow(
/Invalid CalDAV calendar data/
);
});
test('one malformed busy event rejects the whole calendar instead of silently dropping it', () => {
const invalid = multipleEvents.replace('DTSTART:20260416T090000Z', 'DTSTART:invalid');
expect(() => parseExpandedCalDavEvents(invalid)).toThrow(/Invalid time/);
});
test('rejects missing identity and negative time intervals', () => {
expect(() => parseExpandedCalDavEvents(single.replace('UID:event-1@test\n', ''))).toThrow(
/missing its UID/
);
expect(() =>
parseExpandedCalDavEvents(single.replace('DTEND:20260415T150000Z', 'DTEND:20260415T130000Z'))
).toThrow(/end precedes start/);
});
test.each([
{ name: 'an impossible date', source: single.replaceAll('20260415', '20260230') },
{
name: 'a wrong date-time separator',
source: single.replace('20260415T140000Z', '20260415X140000Z')
},
{
name: 'trailing date-time text',
source: single.replace('20260415T140000Z', '20260415T140000Zgarbage')
},
{
name: 'a trailing Unicode separator in a timestamp',
source: single.replace('20260415T140000Z', '20260415T140000Z\u2028')
},
{
name: 'a trailing Unicode separator in a duration',
source: withDuration.replace('PT45M', 'PT45M\u2029')
},
{ name: 'a malformed duration', source: withDuration.replace('PT45M', 'garbage') }
])('rejects $name before value conversion can hide malformed input', ({ source }) => {
expect(() => parseExpandedCalDavEvents(source)).toThrow(/CalDAV busy event/);
});
test.each([
['+P1D', '2026-04-21T09:00:00Z'],
['+P1W', '2026-04-27T09:00:00Z']
])('accepts the explicit positive duration %s', (duration, expectedEnd) => {
const [busy] = parseExpandedCalDavEvents(withDuration.replace('PT45M', duration));
expect(busy.end.toString()).toBe(expectedEnd);
});
test('floating DATE-TIME values use the same UTC interpretation requested from the server', () => {
vi.stubEnv('TZ', 'Asia/Tokyo');
try {
const source = single
.replace('20260415T140000Z', '20260415T140000')
.replace('20260415T150000Z', '20260415T150000');
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.start.toString()).toBe('2026-04-15T14:00:00Z');
expect(busy.end.toString()).toBe('2026-04-15T15:00:00Z');
} finally {
vi.unstubAllEnvs();
}
});
test('DURATION uses elapsed UTC time even across the host’s DST boundary', () => {
vi.stubEnv('TZ', 'America/New_York');
try {
const source = withDuration
.replace('20260420T090000Z', '20260307T120000Z')
.replace('PT45M', 'P2D');
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.end.toString()).toBe('2026-03-09T12:00:00Z');
} finally {
vi.unstubAllEnvs();
}
});
test('component-marker text in a description cannot truncate a later duration', () => {
const source = withDuration.replace(
'DURATION:PT45M',
'DESCRIPTION:Discuss END:VEVENT handling\nDURATION:PT45M'
);
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.start.toString()).toBe('2026-04-20T09:00:00Z');
expect(busy.end.toString()).toBe('2026-04-20T09:45:00Z');
});
test.each(['STATUS:CANCELLED', 'TRANSP:TRANSPARENT'])(
'component-marker text cannot hide a later %s',
(property) => {
const source = single.replace(
'SUMMARY:Lunch',
`DESCRIPTION:Discuss END:VEVENT handling\n${property}`
);
expect(parseExpandedCalDavEvents(source)).toEqual([]);
}
);
test('component-marker text in an opaque UID is preserved', () => {
const uid = 'Mixed-END:VEVENT-ID@Test';
const [busy] = parseExpandedCalDavEvents(single.replace('event-1@test', uid));
expect(busy.uid).toBe(uid);
expect(busy.start.toString()).toBe('2026-04-15T14:00:00Z');
expect(busy.end.toString()).toBe('2026-04-15T15:00:00Z');
});
test.each([
['missing', single.replace('VERSION:2.0\n', '')],
['unsupported', single.replace('VERSION:2.0', 'VERSION:1.0')],
[
'only inside an event',
single.replace('VERSION:2.0\n', '').replace('SUMMARY:Lunch', 'VERSION:2.0')
]
])('rejects a calendar whose version is %s', (_name, source) => {
expect(() => parseExpandedCalDavEvents(source)).toThrow(/Invalid CalDAV calendar data/);
});
test('returns empty list on empty calendar', () => {
const empty = `BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//test//EN\nEND:VCALENDAR`;
expect(parseExpandedCalDavEvents(empty)).toEqual([]);
});
test('ignores events with TRANSP:TRANSPARENT (free)', () => {
const transparent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:free@test
DTSTAMP:20260101T000000Z
DTSTART:20260415T140000Z
DTEND:20260415T150000Z
TRANSP:TRANSPARENT
SUMMARY:Free event
END:VEVENT
END:VCALENDAR`;
expect(parseExpandedCalDavEvents(transparent)).toEqual([]);
});
test('includes events with TRANSP:OPAQUE (busy)', () => {
const opaque = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:busy@test
DTSTAMP:20260101T000000Z
DTSTART:20260415T140000Z
DTEND:20260415T150000Z
TRANSP:OPAQUE
SUMMARY:Busy event
END:VEVENT
END:VCALENDAR`;
const busy = parseExpandedCalDavEvents(opaque);
expect(busy).toHaveLength(1);
expect(busy[0].uid).toBe('busy@test');
});
test('ignores events with STATUS:CANCELLED', () => {
const cancelled = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:cancelled@test
DTSTAMP:20260101T000000Z
DTSTART:20260415T140000Z
DTEND:20260415T150000Z
STATUS:CANCELLED
SUMMARY:Cancelled event
END:VEVENT
END:VCALENDAR`;
expect(parseExpandedCalDavEvents(cancelled)).toEqual([]);
});
test('parses all-day event with explicit DTEND', () => {
const alldayWithEnd = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:allday-end@test
DTSTAMP:20260101T000000Z
DTSTART;VALUE=DATE:20260415
DTEND;VALUE=DATE:20260416
SUMMARY:All day with DTEND
END:VEVENT
END:VCALENDAR`;
const busy = parseExpandedCalDavEvents(alldayWithEnd);
expect(busy).toHaveLength(1);
expect(busy[0].uid).toBe('allday-end@test');
expect(busy[0].start.toString()).toBe('2026-04-15T00:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-16T00:00:00Z');
});
test('parses single-day all-day event without DTEND (RFC 5545 default 1 day duration)', () => {
const alldayNoEnd = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:allday-noend@test
DTSTAMP:20260101T000000Z
DTSTART;VALUE=DATE:20260415
SUMMARY:All day single day
END:VEVENT
END:VCALENDAR`;
const busy = parseExpandedCalDavEvents(alldayNoEnd);
expect(busy).toHaveLength(1);
expect(busy[0].uid).toBe('allday-noend@test');
expect(busy[0].start.toString()).toBe('2026-04-15T00:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-16T00:00:00Z');
});
test('parses timed event without DTEND or DURATION (0 duration)', () => {
const timedNoEnd = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:timed-noend@test
DTSTAMP:20260101T000000Z
DTSTART:20260415T140000Z
SUMMARY:Zero duration
END:VEVENT
END:VCALENDAR`;
const busy = parseExpandedCalDavEvents(timedNoEnd);
expect(busy).toHaveLength(1);
expect(busy[0].uid).toBe('timed-noend@test');
expect(busy[0].start.toString()).toBe('2026-04-15T14:00:00Z');
expect(busy[0].end.toString()).toBe('2026-04-15T14:00:00Z');
});
test.each([
['unclosed VTODO', 'BEGIN:VTODO\nUID:todo@test'],
['bare upstream failure', 'upstream failure'],
['mismatched component end', 'BEGIN:VEVENT\nEND:VALARM'],
['unrequested VTODO', 'BEGIN:VTODO\nEND:VTODO'],
['standalone VALARM', 'BEGIN:VALARM\nEND:VALARM'],
['nested VCALENDAR root', 'BEGIN:VCALENDAR\nEND:VCALENDAR']
])('rejects an apparently empty calendar containing %s', (_name, contents) => {
const source = `BEGIN:VCALENDAR\nVERSION:2.0\n${contents}\nEND:VCALENDAR`;
expect(() => parseExpandedCalDavEvents(source)).toThrow(/Invalid CalDAV calendar data/);
});
test('rejects multiple VCALENDAR roots rather than importing only one', () => {
expect(() => parseExpandedCalDavEvents(`${single}\n${single}`)).toThrow(
/Invalid CalDAV calendar data/
);
});
test.each([
['DTSTART', 'DTSTART:20260415T160000Z'],
['DTEND', 'DTEND:20260415T170000Z'],
['UID', 'uid:Other-UID@Test'],
['DURATION', 'DURATION:PT45M\nduration:PT90M'],
['RECURRENCE-ID', 'RECURRENCE-ID:20260415T130000Z\nrecurrence-id:20260415T140000Z'],
['STATUS', 'STATUS:CANCELLED\nstatus:CONFIRMED'],
['TRANSP', 'TRANSP:TRANSPARENT\ntransp:OPAQUE']
])('rejects duplicate %s before conversion can choose the last value', (name, extra) => {
const source = single.replace('SUMMARY:Lunch', extra);
expect(() => parseExpandedCalDavEvents(source)).toThrow(new RegExp(`Duplicate ${name}`));
});
test('rejects mutually exclusive DTEND and DURATION', () => {
const source = single.replace('SUMMARY:Lunch', 'DURATION:PT1H');
expect(() => parseExpandedCalDavEvents(source)).toThrow(/both DTEND and DURATION/);
});
test.each([
['DATE start with DATE-TIME end', 'DTSTART;VALUE=DATE:20260415', 'DTEND:20260416T000000Z'],
['DATE-TIME start with DATE end', 'DTSTART:20260415T000000Z', 'DTEND;VALUE=DATE:20260416']
])('rejects mismatched VALUE kinds: %s', (_name, start, end) => {
const source = single
.replace('DTSTART:20260415T140000Z', start)
.replace('DTEND:20260415T150000Z', end);
expect(() => parseExpandedCalDavEvents(source)).toThrow(/VALUE kinds differ/);
});
test.each(['PT24H', 'P1DT1H', 'P1DT0H'])(
'rejects time components in a DATE duration: %s',
(duration) => {
const source = withDuration
.replace('DTSTART:20260420T090000Z', 'DTSTART;VALUE=DATE:20260420')
.replace('PT45M', duration);
expect(() => parseExpandedCalDavEvents(source)).toThrow(
/DATE duration must use only weeks or days/
);
}
);
test.each([
['P2D', '2026-04-22T00:00:00Z'],
['+P1W', '2026-04-27T00:00:00Z']
])('accepts week/day-only DATE duration: %s', (duration, expectedEnd) => {
const source = withDuration
.replace('DTSTART:20260420T090000Z', 'DTSTART;VALUE=DATE:20260420')
.replace('PT45M', duration);
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.end.toString()).toBe(expectedEnd);
});
test('accepts case-insensitive structural/property names while preserving UID case', () => {
const source = single.toLowerCase().replace('uid:event-1@test', 'uId:MixedCase-ID@Test');
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.uid).toBe('MixedCase-ID@Test');
expect(busy.start.toString()).toBe('2026-04-15T14:00:00Z');
expect(busy.end.toString()).toBe('2026-04-15T15:00:00Z');
});
test.each(['sTaTuS:cAnCeLlEd', 'tRaNsP:tRaNsPaReNt'])(
'filters case-insensitive free/cancelled enum: %s',
(property) => {
expect(parseExpandedCalDavEvents(single.replace('SUMMARY:Lunch', property))).toEqual([]);
}
);
test('normalizes case-insensitive VALUE parameters', () => {
const source = single
.replace('DTSTART:20260415T140000Z', 'dtstart;value="date":20260415')
.replace('DTEND:20260415T150000Z', 'dtend;value=date:20260416');
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.start.toString()).toBe('2026-04-15T00:00:00Z');
expect(busy.end.toString()).toBe('2026-04-16T00:00:00Z');
});
test('keeps multiple expanded occurrences with the same case-sensitive UID', () => {
const source = multipleEvents
.replace('UID:a@test', 'UID:Series-ID@Test')
.replace('UID:b@test', 'UID:Series-ID@Test');
const busy = parseExpandedCalDavEvents(source);
expect(busy.map((event) => [event.uid, event.start.toString()])).toEqual([
['Series-ID@Test', '2026-04-15T14:00:00Z'],
['Series-ID@Test', '2026-04-16T09:00:00Z']
]);
});
test.each([
[
'VTODO inside an event',
'BEGIN:VTODO\nUID:other\nDTSTART:20260415T010000Z\nDTEND:20260415T020000Z\nSTATUS:CANCELLED\nEND:VTODO'
],
['alarm inside another alarm', 'BEGIN:VALARM\nBEGIN:VALARM\nEND:VALARM\nEND:VALARM']
])('rejects %s rather than letting its fields affect the event', (_name, nested) => {
const source = single.replace('END:VEVENT', `${nested}\nEND:VEVENT`);
expect(() => parseExpandedCalDavEvents(source)).toThrow(/unexpected component hierarchy/);
});
test('nested VALARM DURATION and TRIGGER do not become VEVENT boundaries', () => {
const alarm = `begin:valarm
ACTION:DISPLAY
DESCRIPTION:Mixed-case reminder
TRIGGER:-PT15M
DURATION:PT5M
REPEAT:2
end:valarm`;
const source = withDuration.replace('SUMMARY:Standup', `${alarm}\n${alarm}`);
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.start.toString()).toBe('2026-04-20T09:00:00Z');
expect(busy.end.toString()).toBe('2026-04-20T09:45:00Z');
});
test('a DATE event can have a timed nested alarm without an event DURATION', () => {
const source = single.replace('DTSTART:20260415T140000Z', 'DTSTART;VALUE=DATE:20260415').replace(
'DTEND:20260415T150000Z',
`BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-PT1H
DURATION:PT5M
REPEAT:2
END:VALARM`
);
const [busy] = parseExpandedCalDavEvents(source);
expect(busy.end.toString()).toBe('2026-04-16T00:00:00Z');
});
test('accepts a genuine empty calendar with case-insensitive names', () => {
expect(
parseExpandedCalDavEvents(
'begin:vcalendar\r\nversion:2.0\r\nprodid:-//Test//EN\r\nend:vcalendar\r\n'
)
).toEqual([]);
});