Skip to content
File

Blob: tests/worker/protocol/caldav/ical.test.ts

typescript258 lines
1import { describe, expect, it } from "vitest";
2 
3import { parseICalendarBytes } from "@/worker/caldav/ical/parse";
4import { expandCalendarData } from "@/worker/caldav/ical/render";
5import { calendarObjectMatchesFilters } from "@/worker/caldav/ical/search";
6import { utf8ArrayBuffer } from "@tests/worker/helpers/bytes";
7 
8const bounds = { maxYears: 5, maxInstances: 10_000 };
9 
10function ms(value: string): number {
11 const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value);
12 if (!match) throw new Error(`Invalid test timestamp ${value}`);
13 return Date.UTC(
14 Number(match[1]),
15 Number(match[2]) - 1,
16 Number(match[3]),
17 Number(match[4]),
18 Number(match[5]),
19 Number(match[6]),
20 );
21}
22 
23function ical(lines: string[]): string {
24 return [...lines, ""].join("\r\n");
25}
26 
27describe("CalDAV iCalendar helpers", () => {
28 it("indexes recurrence sets with RDATE and EXDATE", () => {
29 const body = ical([
30 "BEGIN:VCALENDAR",
31 "VERSION:2.0",
32 "PRODID:-//dab//tests//EN",
33 "BEGIN:VEVENT",
34 "DTSTAMP:20260101T000000Z",
35 "UID:recurrence-set@example.test",
36 "SUMMARY:Recurrence Set",
37 "DTSTART:20260521T120000Z",
38 "DTEND:20260521T130000Z",
39 "RRULE:FREQ=DAILY;COUNT=2",
40 "RDATE:20260524T120000Z",
41 "EXDATE:20260522T120000Z",
42 "END:VEVENT",
43 "END:VCALENDAR",
44 ]);
45 
46 const parsed = parseICalendarBytes(utf8ArrayBuffer(body), 1_048_576, bounds);
47 
48 expect(parsed.hasRecurrence).toBe(true);
49 expect(parsed.recurrenceMinMs).toBe(ms("20260521T120000Z"));
50 expect(parsed.recurrenceMaxMs).toBe(ms("20260524T130000Z"));
51 expect(
52 calendarObjectMatchesFilters(
53 body,
54 [{ kind: "time-range", range: { startMs: ms("20260522T000000Z"), endMs: ms("20260523T000000Z") } }],
55 bounds,
56 ),
57 ).toBe(false);
58 expect(
59 calendarObjectMatchesFilters(
60 body,
61 [{ kind: "time-range", range: { startMs: ms("20260524T000000Z"), endMs: ms("20260525T000000Z") } }],
62 bounds,
63 ),
64 ).toBe(true);
65 });
66 
67 it("matches generic property filters and VTODO time ranges", () => {
68 const body = ical([
69 "BEGIN:VCALENDAR",
70 "VERSION:2.0",
71 "PRODID:-//dab//tests//EN",
72 "BEGIN:VTODO",
73 "DTSTAMP:20260101T000000Z",
74 "UID:todo@example.test",
75 "SUMMARY:Todo",
76 "DTSTART:20260521T120000Z",
77 "DUE:20260523T120000Z",
78 "CATEGORIES:Focus,Team",
79 "END:VTODO",
80 "END:VCALENDAR",
81 ]);
82 
83 expect(
84 calendarObjectMatchesFilters(
85 body,
86 [
87 { kind: "component", component: "VTODO" },
88 { kind: "property-text", name: "CATEGORIES", text: "focus" },
89 { kind: "property-defined", name: "LOCATION", defined: false },
90 { kind: "time-range", range: { startMs: ms("20260522T000000Z"), endMs: ms("20260522T010000Z") } },
91 ],
92 bounds,
93 ),
94 ).toBe(true);
95 expect(
96 calendarObjectMatchesFilters(
97 body,
98 [{ kind: "time-range", range: { startMs: ms("20260524T000000Z"), endMs: ms("20260525T000000Z") } }],
99 bounds,
100 ),
101 ).toBe(false);
102 });
103 
104 it("indexes IANA TZID date-times for time-range matching", () => {
105 const body = ical([
106 "BEGIN:VCALENDAR",
107 "VERSION:2.0",
108 "PRODID:-//dab//tests//EN",
109 "BEGIN:VEVENT",
110 "DTSTAMP:20260101T000000Z",
111 "UID:tzid@example.test",
112 "SUMMARY:TZID",
113 "DTSTART;TZID=America/Los_Angeles:20260521T090000",
114 "DTEND;TZID=America/Los_Angeles:20260521T100000",
115 "END:VEVENT",
116 "END:VCALENDAR",
117 ]);
118 
119 const parsed = parseICalendarBytes(utf8ArrayBuffer(body), 1_048_576, bounds);
120 expect(parsed.dtstartMs).toBe(ms("20260521T160000Z"));
121 expect(
122 calendarObjectMatchesFilters(
123 body,
124 [{ kind: "time-range", range: { startMs: ms("20260521T153000Z"), endMs: ms("20260521T163000Z") } }],
125 bounds,
126 ),
127 ).toBe(true);
128 });
129 
130 it("treats floating date-times as UTC for indexed time-range matching", () => {
131 const body = ical([
132 "BEGIN:VCALENDAR",
133 "VERSION:2.0",
134 "PRODID:-//dab//tests//EN",
135 "BEGIN:VEVENT",
136 "DTSTAMP:20260101T000000Z",
137 "UID:floating@example.test",
138 "SUMMARY:Floating",
139 "DTSTART:20260521T090000",
140 "DTEND:20260521T100000",
141 "END:VEVENT",
142 "END:VCALENDAR",
143 ]);
144 
145 const parsed = parseICalendarBytes(utf8ArrayBuffer(body), 1_048_576, bounds);
146 expect(parsed.dtstartMs).toBe(ms("20260521T090000Z"));
147 expect(
148 calendarObjectMatchesFilters(
149 body,
150 [{ kind: "time-range", range: { startMs: ms("20260521T083000Z"), endMs: ms("20260521T093000Z") } }],
151 bounds,
152 ),
153 ).toBe(true);
154 });
155 
156 it("rejects unsupported RRULE parts instead of silently broadening results", () => {
157 const body = ical([
158 "BEGIN:VCALENDAR",
159 "VERSION:2.0",
160 "PRODID:-//dab//tests//EN",
161 "BEGIN:VEVENT",
162 "DTSTAMP:20260101T000000Z",
163 "UID:unsupported-rrule@example.test",
164 "SUMMARY:Unsupported",
165 "DTSTART:20260521T120000Z",
166 "DTEND:20260521T130000Z",
167 "RRULE:FREQ=MONTHLY;BYSETPOS=1;BYDAY=MO",
168 "END:VEVENT",
169 "END:VCALENDAR",
170 ]);
171 
172 expect(() => parseICalendarBytes(utf8ArrayBuffer(body), 1_048_576, bounds)).toThrow(
173 "Unsupported RRULE part BYSETPOS",
174 );
175 });
176 
177 it("expands recurrence overrides and omits cancelled instances", () => {
178 const body = ical([
179 "BEGIN:VCALENDAR",
180 "VERSION:2.0",
181 "PRODID:-//dab//tests//EN",
182 "BEGIN:VEVENT",
183 "DTSTAMP:20260101T000000Z",
184 "UID:expanded@example.test",
185 "SUMMARY:Daily",
186 "DTSTART:20260521T120000Z",
187 "DTEND:20260521T130000Z",
188 "RRULE:FREQ=DAILY;COUNT=3",
189 "END:VEVENT",
190 "BEGIN:VEVENT",
191 "DTSTAMP:20260101T000000Z",
192 "UID:expanded@example.test",
193 "RECURRENCE-ID:20260522T120000Z",
194 "SUMMARY:Moved Daily",
195 "DTSTART:20260522T150000Z",
196 "DTEND:20260522T160000Z",
197 "END:VEVENT",
198 "BEGIN:VEVENT",
199 "DTSTAMP:20260101T000000Z",
200 "UID:expanded@example.test",
201 "RECURRENCE-ID:20260523T120000Z",
202 "STATUS:CANCELLED",
203 "SUMMARY:Cancelled Daily",
204 "DTSTART:20260523T120000Z",
205 "DTEND:20260523T130000Z",
206 "END:VEVENT",
207 "END:VCALENDAR",
208 ]);
209 
210 const expanded = expandCalendarData(
211 body,
212 { startMs: ms("20260521T000000Z"), endMs: ms("20260524T000000Z") },
213 bounds,
214 );
215 
216 expect(expanded).toContain("RECURRENCE-ID:20260522T120000Z");
217 expect(expanded).toContain("SUMMARY:Moved Daily");
218 expect(expanded).toContain("DTSTART:20260522T150000Z");
219 expect(expanded).not.toContain("RRULE:FREQ=DAILY");
220 expect(expanded).not.toContain("SUMMARY:Cancelled Daily");
221 });
222 
223 it("keeps THISANDFUTURE range overrides in expanded output", () => {
224 const body = ical([
225 "BEGIN:VCALENDAR",
226 "VERSION:2.0",
227 "PRODID:-//dab//tests//EN",
228 "BEGIN:VEVENT",
229 "DTSTAMP:20260101T000000Z",
230 "UID:this-and-future@example.test",
231 "SUMMARY:Daily",
232 "DTSTART:20260521T120000Z",
233 "DTEND:20260521T130000Z",
234 "RRULE:FREQ=DAILY;COUNT=4",
235 "END:VEVENT",
236 "BEGIN:VEVENT",
237 "DTSTAMP:20260101T000000Z",
238 "UID:this-and-future@example.test",
239 "RECURRENCE-ID;RANGE=THISANDFUTURE:20260522T120000Z",
240 "SUMMARY:Shift Future",
241 "DTSTART:20260522T150000Z",
242 "DTEND:20260522T160000Z",
243 "END:VEVENT",
244 "END:VCALENDAR",
245 ]);
246 
247 const expanded = expandCalendarData(
248 body,
249 { startMs: ms("20260522T000000Z"), endMs: ms("20260524T000000Z") },
250 bounds,
251 );
252 
253 expect(expanded).toContain("RECURRENCE-ID:20260522T120000Z");
254 expect(expanded).toContain("RECURRENCE-ID:20260523T120000Z");
255 expect(expanded.match(/SUMMARY:Shift Future/g)?.length).toBeGreaterThanOrEqual(2);
256 });
257});