1- import { describe , it , expect , vi , beforeEach } from "vitest" ;
21import { createEvent as createIcsEvent } from "ics" ;
32import { createCalendarObject , updateCalendarObject } from "tsdav" ;
3+ import { beforeEach , describe , expect , it , vi } from "vitest" ;
44
55vi . mock ( "ics" , ( ) => ( {
66 createEvent : vi . fn ( ) ,
@@ -41,7 +41,6 @@ vi.mock("./CalEventParser", () => ({
4141} ) ) ;
4242
4343import type { CalendarServiceEvent } from "@calcom/types/Calendar" ;
44-
4544import BaseCalendarService from "./CalendarService" ;
4645
4746const createMockEvent = ( overrides : Partial < CalendarServiceEvent > = { } ) : CalendarServiceEvent => ( {
@@ -102,6 +101,359 @@ class TestCalendarService extends BaseCalendarService {
102101 }
103102}
104103
104+ describe ( "CalendarService - UID Consistency" , ( ) => {
105+ beforeEach ( ( ) => {
106+ vi . clearAllMocks ( ) ;
107+ } ) ;
108+
109+ it ( "should use event.uid when provided, not generate a new UUID" , async ( ) => {
110+ const service = new TestCalendarService ( ) ;
111+ const bookingUid = "booking-uid-from-database-abc123" ;
112+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:${ bookingUid } \r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
113+
114+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
115+ error : null as unknown as Error ,
116+ value : mockIcsOutput ,
117+ } ) ;
118+
119+ const event = createMockEvent ( { uid : bookingUid } ) ;
120+ const result = await service . createEvent ( event , 1 ) ;
121+
122+ expect ( result . uid ) . toBe ( bookingUid ) ;
123+ expect ( result . id ) . toBe ( bookingUid ) ;
124+
125+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
126+ expect ( calledArg . filename ) . toBe ( `${ bookingUid } .ics` ) ;
127+
128+ const icsCallArg = vi . mocked ( createIcsEvent ) . mock . calls [ 0 ] [ 0 ] ;
129+ expect ( icsCallArg . uid ) . toBe ( bookingUid ) ;
130+ } ) ;
131+
132+ it ( "should generate a new UUID when event.uid is not provided" , async ( ) => {
133+ const service = new TestCalendarService ( ) ;
134+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:generated-uuid\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
135+
136+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
137+ error : null as unknown as Error ,
138+ value : mockIcsOutput ,
139+ } ) ;
140+
141+ const event = createMockEvent ( { uid : undefined } ) ;
142+ const result = await service . createEvent ( event , 1 ) ;
143+
144+ expect ( result . uid ) . toBeTruthy ( ) ;
145+ expect ( typeof result . uid ) . toBe ( "string" ) ;
146+ expect ( result . uid . length ) . toBeGreaterThan ( 0 ) ;
147+ } ) ;
148+
149+ it ( "should use the same uid for CalDAV filename and ics UID property" , async ( ) => {
150+ const service = new TestCalendarService ( ) ;
151+ const bookingUid = "consistent-uid-xyz789" ;
152+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:${ bookingUid } \r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
153+
154+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
155+ error : null as unknown as Error ,
156+ value : mockIcsOutput ,
157+ } ) ;
158+
159+ const event = createMockEvent ( { uid : bookingUid } ) ;
160+ await service . createEvent ( event , 1 ) ;
161+
162+ const icsCallArg = vi . mocked ( createIcsEvent ) . mock . calls [ 0 ] [ 0 ] ;
163+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
164+
165+ expect ( icsCallArg . uid ) . toBe ( bookingUid ) ;
166+ expect ( calledArg . filename ) . toBe ( `${ bookingUid } .ics` ) ;
167+ expect ( icsCallArg . uid ) . toBe ( calledArg . filename ?. replace ( ".ics" , "" ) ) ;
168+ } ) ;
169+ } ) ;
170+
171+ describe ( "CalendarService - VTIMEZONE Generation" , ( ) => {
172+ beforeEach ( ( ) => {
173+ vi . clearAllMocks ( ) ;
174+ } ) ;
175+
176+ it ( "should include VTIMEZONE block in created CalDAV event" , async ( ) => {
177+ const service = new TestCalendarService ( ) ;
178+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
179+
180+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
181+ error : null as unknown as Error ,
182+ value : mockIcsOutput ,
183+ } ) ;
184+
185+ const event = createMockEvent ( {
186+ startTime : "2023-06-15T15:00:00Z" ,
187+ endTime : "2023-06-15T16:00:00Z" ,
188+ organizer : {
189+ name : "Test" ,
190+ email : "test@example.com" ,
191+ timeZone : "America/Chicago" ,
192+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
193+ } ,
194+ } ) ;
195+
196+ await service . createEvent ( event , 1 ) ;
197+
198+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
199+ const iCalString = calledArg . iCalString ;
200+
201+ expect ( iCalString ) . toContain ( "BEGIN:VTIMEZONE" ) ;
202+ expect ( iCalString ) . toContain ( "END:VTIMEZONE" ) ;
203+ expect ( iCalString ) . toContain ( "TZID:America/Chicago" ) ;
204+ } ) ;
205+
206+ it ( "should use TZID in DTSTART instead of UTC Z suffix" , async ( ) => {
207+ const service = new TestCalendarService ( ) ;
208+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
209+
210+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
211+ error : null as unknown as Error ,
212+ value : mockIcsOutput ,
213+ } ) ;
214+
215+ const event = createMockEvent ( {
216+ startTime : "2023-06-15T15:00:00Z" ,
217+ endTime : "2023-06-15T16:00:00Z" ,
218+ organizer : {
219+ name : "Test" ,
220+ email : "test@example.com" ,
221+ timeZone : "America/Chicago" ,
222+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
223+ } ,
224+ } ) ;
225+
226+ await service . createEvent ( event , 1 ) ;
227+
228+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
229+ const iCalString = calledArg . iCalString ;
230+
231+ expect ( iCalString ) . toContain ( "DTSTART;TZID=America/Chicago:" ) ;
232+ const unfolded = iCalString . replace ( / \r ? \n [ \t ] / g, "" ) ;
233+ expect ( unfolded ) . not . toMatch ( / ^ D T S T A R T : [ 0 - 9 ] { 8 } T [ 0 - 9 ] { 6 } Z / m) ;
234+ } ) ;
235+
236+ it ( "should convert UTC time to local time correctly for America/Chicago" , async ( ) => {
237+ const service = new TestCalendarService ( ) ;
238+ // 2023-01-15T15:00:00Z = 2023-01-15T09:00:00 in America/Chicago (UTC-6 in January)
239+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230115T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
240+
241+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
242+ error : null as unknown as Error ,
243+ value : mockIcsOutput ,
244+ } ) ;
245+
246+ const event = createMockEvent ( {
247+ startTime : "2023-01-15T15:00:00Z" ,
248+ endTime : "2023-01-15T16:00:00Z" ,
249+ organizer : {
250+ name : "Test" ,
251+ email : "test@example.com" ,
252+ timeZone : "America/Chicago" ,
253+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
254+ } ,
255+ } ) ;
256+
257+ await service . createEvent ( event , 1 ) ;
258+
259+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
260+ const iCalString = calledArg . iCalString ;
261+ const unfolded = iCalString . replace ( / \r ? \n [ \t ] / g, "" ) ;
262+
263+ expect ( unfolded ) . toContain ( "DTSTART;TZID=America/Chicago:20230115T090000" ) ;
264+ } ) ;
265+
266+ it ( "should place VTIMEZONE block before BEGIN:VEVENT" , async ( ) => {
267+ const service = new TestCalendarService ( ) ;
268+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
269+
270+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
271+ error : null as unknown as Error ,
272+ value : mockIcsOutput ,
273+ } ) ;
274+
275+ const event = createMockEvent ( {
276+ startTime : "2023-06-15T15:00:00Z" ,
277+ endTime : "2023-06-15T16:00:00Z" ,
278+ organizer : {
279+ name : "Test" ,
280+ email : "test@example.com" ,
281+ timeZone : "America/Chicago" ,
282+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
283+ } ,
284+ } ) ;
285+ await service . createEvent ( event , 1 ) ;
286+
287+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
288+ const iCalString = calledArg . iCalString ;
289+
290+ const vtimezoneIdx = iCalString . indexOf ( "BEGIN:VTIMEZONE" ) ;
291+ const veventIdx = iCalString . indexOf ( "BEGIN:VEVENT" ) ;
292+
293+ expect ( vtimezoneIdx ) . toBeGreaterThan ( - 1 ) ;
294+ expect ( veventIdx ) . toBeGreaterThan ( - 1 ) ;
295+ expect ( vtimezoneIdx ) . toBeLessThan ( veventIdx ) ;
296+ } ) ;
297+
298+ it ( "should produce valid 8-digit DTSTART dates in VTIMEZONE blocks" , async ( ) => {
299+ const service = new TestCalendarService ( ) ;
300+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T120000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
301+
302+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
303+ error : null as unknown as Error ,
304+ value : mockIcsOutput ,
305+ } ) ;
306+
307+ const event = createMockEvent ( {
308+ startTime : "2023-06-15T12:00:00Z" ,
309+ endTime : "2023-06-15T13:00:00Z" ,
310+ organizer : {
311+ name : "Test" ,
312+ email : "test@example.com" ,
313+ timeZone : "America/New_York" ,
314+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
315+ } ,
316+ } ) ;
317+
318+ await service . createEvent ( event , 1 ) ;
319+
320+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
321+ const iCalString = calledArg . iCalString ;
322+
323+ const vtimezoneBlock = iCalString . slice (
324+ iCalString . indexOf ( "BEGIN:VTIMEZONE" ) ,
325+ iCalString . indexOf ( "END:VTIMEZONE" ) + 13
326+ ) ;
327+ const dtStartMatches = vtimezoneBlock . match ( / D T S T A R T : ( \d + ) T / g) ;
328+ expect ( dtStartMatches ) . not . toBeNull ( ) ;
329+ for ( const match of dtStartMatches ?? [ ] ) {
330+ const dateStr = match . replace ( "DTSTART:" , "" ) . replace ( "T" , "" ) ;
331+ expect ( dateStr ) . toHaveLength ( 8 ) ; // YYYYMMDD = 8 chars
332+ }
333+ } ) ;
334+
335+ it ( "should use pre-transition local time for VTIMEZONE DTSTART (RFC 5545)" , async ( ) => {
336+ const service = new TestCalendarService ( ) ;
337+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T120000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
338+
339+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
340+ error : null as unknown as Error ,
341+ value : mockIcsOutput ,
342+ } ) ;
343+
344+ const event = createMockEvent ( {
345+ startTime : "2023-06-15T12:00:00Z" ,
346+ endTime : "2023-06-15T13:00:00Z" ,
347+ organizer : {
348+ name : "Test" ,
349+ email : "test@example.com" ,
350+ timeZone : "America/New_York" ,
351+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
352+ } ,
353+ } ) ;
354+
355+ await service . createEvent ( event , 1 ) ;
356+
357+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
358+ const iCalString = calledArg . iCalString ;
359+
360+ const vtimezoneBlock = iCalString . slice (
361+ iCalString . indexOf ( "BEGIN:VTIMEZONE" ) ,
362+ iCalString . indexOf ( "END:VTIMEZONE" ) + 13
363+ ) ;
364+
365+ // America/New_York 2023: spring forward March 12 at 2:00 AM EST,
366+ // fall back November 5 at 2:00 AM EDT.
367+ // RFC 5545 requires DTSTART to use pre-transition local time (02:00),
368+ // not the post-transition time (03:00 for spring forward, 01:00 for fall back).
369+ const daylightBlock = vtimezoneBlock . slice (
370+ vtimezoneBlock . indexOf ( "BEGIN:DAYLIGHT" ) ,
371+ vtimezoneBlock . indexOf ( "END:DAYLIGHT" )
372+ ) ;
373+ expect ( daylightBlock ) . toContain ( "DTSTART:20230312T020000" ) ;
374+
375+ const standardBlock = vtimezoneBlock . slice (
376+ vtimezoneBlock . indexOf ( "BEGIN:STANDARD" ) ,
377+ vtimezoneBlock . indexOf ( "END:STANDARD" )
378+ ) ;
379+ expect ( standardBlock ) . toContain ( "DTSTART:20231105T020000" ) ;
380+ } ) ;
381+
382+ it ( "should use correct DST rules for Southern Hemisphere (Australia/Sydney)" , async ( ) => {
383+ const service = new TestCalendarService ( ) ;
384+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230115T020000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
385+
386+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
387+ error : null as unknown as Error ,
388+ value : mockIcsOutput ,
389+ } ) ;
390+
391+ const event = createMockEvent ( {
392+ startTime : "2023-01-15T02:00:00Z" ,
393+ endTime : "2023-01-15T03:00:00Z" ,
394+ organizer : {
395+ name : "Test" ,
396+ email : "test@example.com" ,
397+ timeZone : "Australia/Sydney" ,
398+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
399+ } ,
400+ } ) ;
401+
402+ await service . createEvent ( event , 1 ) ;
403+
404+ const calledArg = vi . mocked ( createCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
405+ const iCalString = calledArg . iCalString ;
406+
407+ expect ( iCalString ) . toContain ( "BEGIN:VTIMEZONE" ) ;
408+ expect ( iCalString ) . toContain ( "TZID:Australia/Sydney" ) ;
409+ const vtimezoneBlock = iCalString . slice (
410+ iCalString . indexOf ( "BEGIN:VTIMEZONE" ) ,
411+ iCalString . indexOf ( "END:VTIMEZONE" ) + 13
412+ ) ;
413+ expect ( vtimezoneBlock ) . toContain ( "BEGIN:DAYLIGHT" ) ;
414+ expect ( vtimezoneBlock ) . toContain ( "BEGIN:STANDARD" ) ;
415+ } ) ;
416+
417+ it ( "should apply timezone fix to updateEvent as well" , async ( ) => {
418+ const service = new TestCalendarService ( ) ;
419+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR` ;
420+
421+ vi . mocked ( createIcsEvent ) . mockReturnValue ( {
422+ error : null as unknown as Error ,
423+ value : mockIcsOutput ,
424+ } ) ;
425+
426+ ( service as unknown as Record < string , unknown > ) . getEventsByUID = vi . fn ( ) . mockResolvedValue ( [
427+ {
428+ uid : "test-uid" ,
429+ url : "https://caldav.example.com/calendar/test.ics" ,
430+ etag : '"etag123"' ,
431+ } ,
432+ ] ) ;
433+
434+ const event = createMockEvent ( {
435+ uid : "test-uid" ,
436+ startTime : "2023-06-15T15:00:00Z" ,
437+ endTime : "2023-06-15T16:00:00Z" ,
438+ organizer : {
439+ name : "Test" ,
440+ email : "test@example.com" ,
441+ timeZone : "America/Chicago" ,
442+ language : { translate : ( ( key : string ) => key ) as never , locale : "en" } ,
443+ } ,
444+ } ) ;
445+
446+ await service . testUpdateEvent ( "test-uid" , event , "https://caldav.example.com/calendar/" ) ;
447+
448+ const calledArg = vi . mocked ( updateCalendarObject ) . mock . calls [ 0 ] [ 0 ] ;
449+ const data = calledArg . calendarObject . data ;
450+
451+ expect ( data ) . toContain ( "BEGIN:VTIMEZONE" ) ;
452+ expect ( data ) . toContain ( "TZID:America/Chicago" ) ;
453+ expect ( data ) . toContain ( "DTSTART;TZID=America/Chicago:" ) ;
454+ } ) ;
455+ } ) ;
456+
105457describe ( "CalendarService - SCHEDULE-AGENT injection" , ( ) => {
106458 beforeEach ( ( ) => {
107459 vi . clearAllMocks ( ) ;
@@ -366,7 +718,7 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => {
366718
367719 it ( "should preserve other iCal properties unchanged" , async ( ) => {
368720 const service = new TestCalendarService ( ) ;
369- const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VCALENDAR` ;
721+ const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nBEGIN:VEVENT\r\ nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VEVENT \r\nEND:VCALENDAR` ;
370722 vi . mocked ( createIcsEvent ) . mockReturnValue ( {
371723 error : null as unknown as Error ,
372724 value : mockIcsOutput ,
@@ -381,8 +733,6 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => {
381733
382734 expect ( iCalString ) . toContain ( "VERSION:2.0" ) ;
383735 expect ( iCalString ) . toContain ( "PRODID:-//Cal.com//NONSGML//EN" ) ;
384- expect ( iCalString ) . toContain ( "DTSTART:20230101T100000Z" ) ;
385- expect ( iCalString ) . toContain ( "DTEND:20230101T110000Z" ) ;
386736 } ) ;
387737
388738 it ( "should handle empty iCalString gracefully" , async ( ) => {
0 commit comments