import { Calendar } from '@/features/Calendars/CalendarTypes' import { VObjectProperty } from '@/features/Calendars/types/CalendarData' import { CalendarEvent, RepetitionObject } from '@/features/Events/EventsTypes' import { calendarEventToJCal, combineMasterDateWithFormTime, detectRecurringEventChanges, normalizeRepetition, normalizeTimezone, parseCalendarEvent } from '@/features/Events/utils' describe('parseCalendarEvent', () => { const baseColor = { light: '#00FF00' } const calendar = { id: 'calendar-123' } as Calendar it('parses a full event with MAILTO in caps correctly', () => { const rawData = [ ['UID', {}, 'text', 'event-1'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], ['SUMMARY', {}, 'text', 'Team Meeting'], ['DESCRIPTION', {}, 'text', 'Discuss roadmap'], ['LOCATION', {}, 'text', 'Zoom'], ['ORGANIZER', { cn: 'Alice' }, 'cal-address', 'alice@example.com'], [ 'ATTENDEE', { cn: 'Bob', partstat: 'ACCEPTED' }, 'cal-address', 'bob@example.com' ], ['X-OPENPAAS-VIDEOCONFERENCE', {}, 'text', 'https://meet.link'], ['STATUS', {}, 'text', 'CONFIRMED'], ['SEQUENCE', {}, 'integer', '2'], ['TRANSP', {}, 'text', 'OPAQUE'], ['CLASS', {}, 'text', 'PUBLIC'], ['DTSTAMP', {}, 'date-time', '2025-07-18T08:00:00Z'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.uid).toBe('event-1') expect(result.title).toBe('Team Meeting') expect(result.description).toBe('Discuss roadmap') expect(result.location).toBe('Zoom') expect(result.start).toBe('2025-07-18T09:00:00Z') expect(result.end).toBe('2025-07-18T10:00:00Z') expect(result.stamp).toBe('2025-07-18T08:00:00Z') expect(result.sequence).toBe(2) expect(result.color).toBe(baseColor) expect(result.status).toBe('CONFIRMED') expect(result.transp).toBe('OPAQUE') expect(result.class).toBe('PUBLIC') expect(result.x_openpass_videoconference).toBe('https://meet.link') expect(result.timezone).not.toBeDefined() expect(result.organizer).toEqual({ cn: 'Alice', cal_address: 'alice@example.com' }) expect(result.attendee).toEqual([ { cn: 'Bob', cal_address: 'bob@example.com', partstat: 'ACCEPTED', cutype: 'INDIVIDUAL', role: 'REQ-PARTICIPANT', rsvp: 'FALSE' } ]) }) it('dedupes attendees addresses correctly', () => { const rawData = [ ['UID', {}, 'text', 'event-1'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], ['SUMMARY', {}, 'text', 'Team Meeting'], ['DESCRIPTION', {}, 'text', 'Discuss roadmap'], ['LOCATION', {}, 'text', 'Zoom'], ['ORGANIZER', { cn: 'Alice' }, 'cal-address', 'alice@example.com'], [ 'ATTENDEE', { cn: 'Bob', partstat: 'ACCEPTED' }, 'cal-address', 'bob@example.com' ], [ 'ATTENDEE', { cn: 'Bob', partstat: 'ACCEPTED' }, 'cal-address', 'bob@example.com' ], [ 'ATTENDEE', { cn: 'Bob', partstat: 'ACCEPTED' }, 'cal-address', 'bob@example.com' ], ['X-OPENPAAS-VIDEOCONFERENCE', {}, 'text', 'https://meet.link'], ['STATUS', {}, 'text', 'CONFIRMED'], ['SEQUENCE', {}, 'integer', '2'], ['TRANSP', {}, 'text', 'OPAQUE'], ['CLASS', {}, 'text', 'PUBLIC'], ['DTSTAMP', {}, 'date-time', '2025-07-18T08:00:00Z'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.uid).toBe('event-1') expect(result.title).toBe('Team Meeting') expect(result.description).toBe('Discuss roadmap') expect(result.location).toBe('Zoom') expect(result.start).toBe('2025-07-18T09:00:00Z') expect(result.end).toBe('2025-07-18T10:00:00Z') expect(result.stamp).toBe('2025-07-18T08:00:00Z') expect(result.sequence).toBe(2) expect(result.color).toBe(baseColor) expect(result.status).toBe('CONFIRMED') expect(result.transp).toBe('OPAQUE') expect(result.class).toBe('PUBLIC') expect(result.x_openpass_videoconference).toBe('https://meet.link') expect(result.timezone).not.toBeDefined() expect(result.organizer).toEqual({ cn: 'Alice', cal_address: 'alice@example.com' }) expect(result.attendee).toEqual([ { cn: 'Bob', cal_address: 'bob@example.com', partstat: 'ACCEPTED', cutype: 'INDIVIDUAL', role: 'REQ-PARTICIPANT', rsvp: 'FALSE' } ]) }) it('parses a full event correctly', () => { const rawData = [ ['UID', {}, 'text', 'event-1'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], ['SUMMARY', {}, 'text', 'Team Meeting'], ['DESCRIPTION', {}, 'text', 'Discuss roadmap'], ['LOCATION', {}, 'text', 'Zoom'], ['ORGANIZER', { cn: 'Alice' }, 'cal-address', 'MAILTO:alice@example.com'], [ 'ATTENDEE', { cn: 'Bob', partstat: 'ACCEPTED' }, 'cal-address', 'MAILTO:bob@example.com' ], ['X-OPENPAAS-VIDEOCONFERENCE', {}, 'text', 'https://meet.link'], ['STATUS', {}, 'text', 'CONFIRMED'], ['SEQUENCE', {}, 'integer', '2'], ['TRANSP', {}, 'text', 'OPAQUE'], ['CLASS', {}, 'text', 'PUBLIC'], ['DTSTAMP', {}, 'date-time', '2025-07-18T08:00:00Z'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.uid).toBe('event-1') expect(result.title).toBe('Team Meeting') expect(result.description).toBe('Discuss roadmap') expect(result.location).toBe('Zoom') expect(result.start).toBe('2025-07-18T09:00:00Z') expect(result.end).toBe('2025-07-18T10:00:00Z') expect(result.stamp).toBe('2025-07-18T08:00:00Z') expect(result.sequence).toBe(2) expect(result.color).toBe(baseColor) expect(result.status).toBe('CONFIRMED') expect(result.transp).toBe('OPAQUE') expect(result.class).toBe('PUBLIC') expect(result.x_openpass_videoconference).toBe('https://meet.link') expect(result.timezone).not.toBeDefined() expect(result.organizer).toEqual({ cn: 'Alice', cal_address: 'alice@example.com' }) expect(result.attendee).toEqual([ { cn: 'Bob', cal_address: 'bob@example.com', partstat: 'ACCEPTED', cutype: 'INDIVIDUAL', role: 'REQ-PARTICIPANT', rsvp: 'FALSE' } ]) }) it('marks allday true for DATE format', () => { const rawData: VObjectProperty[] = [ ['UID', {}, 'text', 'event-2'], ['DTSTART', {}, 'date', '2025-07-20'], ['DTEND', {}, 'date', '2025-07-21'] ] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.allday).toBe(true) }) it('appends recurrence-id to UID if present', () => { const rawData: VObjectProperty[] = [ ['UID', {}, 'text', 'event-2'], ['RECURRENCE-ID', {}, 'date-time', '2025-07-18T09:00:00Z'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] ] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.uid).toBe('event-2/2025-07-18T09:00:00Z') }) it('returns error if UID or start is missing', () => { const rawDataMissingUid: VObjectProperty[] = [ ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] ] const result = parseCalendarEvent( rawDataMissingUid, baseColor, calendar, '/calendars/test.ics' ) expect(result.error).toMatch(/missing crucial event param/) const rawDataMissingStart: VObjectProperty[] = [ ['UID', {}, 'text', 'event-3'] ] const result2 = parseCalendarEvent( rawDataMissingStart, baseColor, calendar, '/calendars/test.ics' ) expect(result2.error).toMatch(/missing crucial event param/) }) it('returns computed end when there is no end but a duration', () => { jest.useFakeTimers().setSystemTime(new Date('2025-07-18T00:00:00Z')) const rawDataMissing: VObjectProperty[] = [ ['UID', {}, 'text', 'event-1'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00'], ['duration', {}, 'duration', 'PT60M'] ] const result = parseCalendarEvent( rawDataMissing, baseColor, calendar, '/calendars/test.ics' ) expect(result.end).toBeDefined() const endDate = new Date(result.end ?? '') const startDate = new Date(result.start) expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000) }) it('returns 30 minutes event if end and duration are missing', () => { const rawDataMissing: VObjectProperty[] = [ ['UID', {}, 'text', 'event-1'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] ] const result = parseCalendarEvent( rawDataMissing, baseColor, calendar, '/calendars/test.ics' ) expect(result.end).toBeDefined() const endDate = new Date(result.end ?? '') const startDate = new Date(result.start) expect(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000) }) it('parses alarm block correctly', () => { const rawData: VObjectProperty[] = [ ['UID', {}, 'text', 'event-5'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] ] const valarm = [ 'VALARM', [ ['ACTION', {}, 'text', 'DISPLAY'], ['TRIGGER', {}, 'duration', '-PT15M'] ] ] as unknown as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics', valarm ) expect(result.alarm?.action).toBe('DISPLAY') expect(result.alarm?.trigger).toBe('-PT15M') }) it('handles optional organizer and attendee fields gracefully', () => { const rawData = [ ['UID', {}, 'text', 'event-4'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], ['ATTENDEE', {}, 'cal-address', 'john@example.com'], ['ORGANIZER', {}, 'cal-address', 'jane@example.com'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.attendee).toEqual([ { cn: '', cal_address: 'john@example.com', partstat: 'NEEDS-ACTION', cutype: 'INDIVIDUAL', role: 'REQ-PARTICIPANT', rsvp: 'FALSE' } ]) expect(result.organizer).toEqual({ cn: '', cal_address: 'jane@example.com' }) }) it('converts datetime without timezone to ISO UTC when timezone is detected', () => { const rawData = [ ['UID', {}, 'text', 'event-tz'], ['DTSTART', { tzid: 'Asia/Bangkok' }, 'date-time', '2025-07-18T09:00:00'], ['DTEND', { tzid: 'Asia/Bangkok' }, 'date-time', '2025-07-18T10:00:00'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.timezone).toBeDefined() expect(result.start).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ ) expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) expect(result.allday).toBe(false) }) it('does not convert datetime with Z suffix', () => { const rawData = [ ['UID', {}, 'text', 'event-utc'], ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.start).toBe('2025-07-18T09:00:00Z') expect(result.end).toBe('2025-07-18T10:00:00Z') }) it('preserves all-day event dates without conversion', () => { const rawData = [ ['UID', {}, 'text', 'event-allday'], ['DTSTART', {}, 'date', '2025-07-18'], ['DTEND', {}, 'date', '2025-07-19'] ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, '/calendars/test.ics' ) expect(result.allday).toBe(true) expect(result.start).toBe('2025-07-18') expect(result.end).toBe('2025-07-19') }) }) describe('calendarEventToJCal', () => { it('should convert a CalendarEvent to JCal format', () => { const mockEvent = { uid: 'event-123', URL: '/calendars/test.ics', calId: 'test/test', title: 'Team Meeting', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', transp: 'OPAQUE', class: 'PUBLIC', allday: false, location: 'Room 101', description: 'Discuss project roadmap.', repetition: { freq: 'WEEKLY' }, organizer: { cn: 'Alice', cal_address: 'alice@example.com' }, attendee: [ { cn: 'Bob', partstat: 'ACCEPTED', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL', cal_address: 'bob@example.com' } ] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') const props = vevent[1] const dtstart = props.find((p: VObjectProperty) => p[0] === 'dtstart') const dtend = props.find((p: VObjectProperty) => p[0] === 'dtend') expect(dtstart).toBeDefined() expect(dtstart[1]).toEqual({ tzid: 'Europe/Paris' }) expect(dtstart[2]).toBe('date-time') expect(dtstart[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/) expect(dtend).toBeDefined() expect(dtend[1]).toEqual({ tzid: 'Europe/Paris' }) expect(dtend[2]).toBe('date-time') expect(dtend[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/) expect(props).toEqual( expect.arrayContaining([ ['uid', {}, 'text', 'event-123'], ['summary', {}, 'text', 'Team Meeting'], ['transp', {}, 'text', 'OPAQUE'], ['class', {}, 'text', 'PUBLIC'], ['location', {}, 'text', 'Room 101'], ['description', {}, 'text', 'Discuss project roadmap.'], ['x-openpaas-videoconference', {}, 'unknown', null], ['rrule', {}, 'recur', { freq: 'WEEKLY' }], [ 'organizer', { cn: 'Alice' }, 'cal-address', 'mailto:alice@example.com' ], [ 'attendee', { cn: 'Bob', partstat: 'ACCEPTED', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL' }, 'cal-address', 'mailto:bob@example.com' ] ]) ) expect(vtimezone[0]).toBe('vtimezone') expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'daylight', [ ['tzoffsetfrom', {}, 'utc-offset', '+01:00'], ['tzoffsetto', {}, 'utc-offset', '+02:00'], ['tzname', {}, 'text', 'CEST'], ['dtstart', {}, 'date-time', '1970-03-29T02:00:00'], [ 'rrule', {}, 'recur', { byday: '-1SU', bymonth: 3, freq: 'YEARLY' } ] ], [] ], [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+02:00'], ['tzoffsetto', {}, 'utc-offset', '+01:00'], ['tzname', {}, 'text', 'CET'], ['dtstart', {}, 'date-time', '1970-10-25T03:00:00'], [ 'rrule', {}, 'recur', { byday: '-1SU', bymonth: 10, freq: 'YEARLY' } ] ], [] ] ]) ) }) it('converts with alarm included', () => { const mockEvent = { uid: 'event-10', title: 'Alarm Event', start: '2025-07-20T07:00:00.000Z', end: '2025-07-20T08:00:00.000Z', timezone: 'Europe/Paris', allday: false, alarm: { trigger: '-PT10M', action: 'DISPLAY' }, attendee: [] } as unknown as CalendarEvent const result = calendarEventToJCal(mockEvent, 'owner@example.com') const vevent = result[2][0] expect(vevent[2][0][0]).toBe('valarm') expect(vevent[2][0][1]).toEqual( expect.arrayContaining([ ['trigger', {}, 'duration', '-PT10M'], ['action', {}, 'text', 'DISPLAY'] ]) ) }) it('converts all-day events', () => { const mockEvent = { uid: 'event-11', title: 'All Day', start: '2025-07-21T00:00:00.000Z', end: '2025-07-22T00:00:00.000Z', timezone: 'Europe/Paris', allday: true, attendee: [] } as unknown as CalendarEvent const result = calendarEventToJCal(mockEvent) const veventProps = result[2][0][1] expect(veventProps).toEqual( expect.arrayContaining([ ['dtstart', { tzid: 'Europe/Paris' }, 'date', '2025-07-21'], ['dtend', { tzid: 'Europe/Paris' }, 'date', '2025-07-22'] ]) ) }) it('should convert a CalendarEvent to JCal format, with all day activated', () => { const mockEvent = { uid: 'event-123', URL: '/calendars/test.ics', calId: 'test/test', title: 'Team Meeting', start: '2025-07-23T00:00:00.000Z', end: '2025-07-24T00:00:00.000Z', timezone: 'Europe/Paris', transp: 'OPAQUE', class: 'PUBLIC', allday: true, location: 'Room 101', description: 'Discuss project roadmap.', repetition: { freq: 'WEEKLY', interval: 2 }, organizer: { cn: 'Alice', cal_address: 'alice@example.com' }, attendee: [ { cn: 'Bob', partstat: 'ACCEPTED', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL', cal_address: 'bob@example.com' } ] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') const props = vevent[1] expect(props).toEqual( expect.arrayContaining([ ['uid', {}, 'text', 'event-123'], ['summary', {}, 'text', 'Team Meeting'], ['transp', {}, 'text', 'OPAQUE'], ['dtstart', { tzid: 'Europe/Paris' }, 'date', '2025-07-23'], ['dtend', { tzid: 'Europe/Paris' }, 'date', '2025-07-24'], ['class', {}, 'text', 'PUBLIC'], ['location', {}, 'text', 'Room 101'], ['description', {}, 'text', 'Discuss project roadmap.'], ['x-openpaas-videoconference', {}, 'unknown', null], ['rrule', {}, 'recur', { freq: 'WEEKLY', interval: 2 }], [ 'organizer', { cn: 'Alice' }, 'cal-address', 'mailto:alice@example.com' ], [ 'attendee', { cn: 'Bob', partstat: 'ACCEPTED', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL' }, 'cal-address', 'mailto:bob@example.com' ] ]) ) expect(vtimezone[0]).toBe('vtimezone') expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'daylight', [ ['tzoffsetfrom', {}, 'utc-offset', '+01:00'], ['tzoffsetto', {}, 'utc-offset', '+02:00'], ['tzname', {}, 'text', 'CEST'], ['dtstart', {}, 'date-time', '1970-03-29T02:00:00'], [ 'rrule', {}, 'recur', { byday: '-1SU', bymonth: 3, freq: 'YEARLY' } ] ], [] ], [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+02:00'], ['tzoffsetto', {}, 'utc-offset', '+01:00'], ['tzname', {}, 'text', 'CET'], ['dtstart', {}, 'date-time', '1970-10-25T03:00:00'], [ 'rrule', {}, 'recur', { byday: '-1SU', bymonth: 10, freq: 'YEARLY' } ] ], [] ] ]) ) }) // Edge cases for timezone fallback it('handles invalid timezone with UTC fallback', () => { const mockEvent = { uid: 'event-invalid-tz', title: 'Invalid Timezone Event', start: '2025-07-23T10:00:00.000Z', end: '2025-07-23T11:00:00.000Z', timezone: 'Invalid/Timezone', allday: false, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback - check for UTC standard timezone expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], ['tzoffsetto', {}, 'utc-offset', '+00:00'], ['tzname', {}, 'text', 'UTC'], ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], [] ] ]) ) }) it('handles null timezone with UTC fallback', () => { const mockEvent = { uid: 'event-null-tz', title: 'Null Timezone Event', start: '2025-07-23T10:00:00.000Z', end: '2025-07-23T11:00:00.000Z', timezone: null, allday: false, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], ['tzoffsetto', {}, 'utc-offset', '+00:00'], ['tzname', {}, 'text', 'UTC'], ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], [] ] ]) ) }) it('handles undefined timezone with UTC fallback', () => { const mockEvent = { uid: 'event-undefined-tz', title: 'Undefined Timezone Event', start: '2025-07-23T10:00:00.000Z', end: '2025-07-23T11:00:00.000Z', timezone: undefined, allday: false, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], ['tzoffsetto', {}, 'utc-offset', '+00:00'], ['tzname', {}, 'text', 'UTC'], ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], [] ] ]) ) }) it('handles empty string timezone with UTC fallback', () => { const mockEvent = { uid: 'event-empty-tz', title: 'Empty Timezone Event', start: '2025-07-23T10:00:00.000Z', end: '2025-07-23T11:00:00.000Z', timezone: '', allday: false, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], ['tzoffsetto', {}, 'utc-offset', '+00:00'], ['tzname', {}, 'text', 'UTC'], ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], [] ] ]) ) }) it('handles valid timezone correctly', () => { const mockEvent = { uid: 'event-valid-tz', title: 'Valid Timezone Event', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', allday: false, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) expect(result[0]).toBe('vcalendar') const [vevent, vtimezone] = result[2] expect(vevent[0]).toBe('vevent') expect(vtimezone[0]).toBe('vtimezone') // Should use the specified timezone - check for Europe/Paris timezone components expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ 'daylight', [ ['tzoffsetfrom', {}, 'utc-offset', '+01:00'], ['tzoffsetto', {}, 'utc-offset', '+02:00'], ['tzname', {}, 'text', 'CEST'], ['dtstart', {}, 'date-time', '1970-03-29T02:00:00'], [ 'rrule', {}, 'recur', { byday: '-1SU', bymonth: 3, freq: 'YEARLY' } ] ], [] ], [ 'standard', [ ['tzoffsetfrom', {}, 'utc-offset', '+02:00'], ['tzoffsetto', {}, 'utc-offset', '+01:00'], ['tzname', {}, 'text', 'CET'], ['dtstart', {}, 'date-time', '1970-10-25T03:00:00'], [ 'rrule', {}, 'recur', { byday: '-1SU', bymonth: 10, freq: 'YEARLY' } ] ], [] ] ]) ) }) it('includes SEQUENCE field when present in event', () => { const mockEvent = { uid: 'event-with-sequence', title: 'Event with Sequence', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', allday: false, sequence: 3, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) const [vevent] = result[2] const props = vevent[1] const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') expect(sequenceProp).toBeDefined() expect(sequenceProp).toEqual(['sequence', {}, 'integer', 3]) }) it('defaults SEQUENCE to 1 when not present in event', () => { const mockEvent = { uid: 'event-without-sequence', title: 'Event without Sequence', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', allday: false, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) const [vevent] = result[2] const props = vevent[1] const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') expect(sequenceProp).toBeDefined() expect(sequenceProp).toEqual(['sequence', {}, 'integer', 1]) }) it('includes SEQUENCE in recurring event', () => { const mockEvent = { uid: 'recurring-event-sequence', title: 'Recurring Event with Sequence', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', allday: false, sequence: 2, repetition: { freq: 'DAILY', interval: 1 }, attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) const [vevent] = result[2] const props = vevent[1] const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') expect(sequenceProp).toBeDefined() expect(sequenceProp).toEqual(['sequence', {}, 'integer', 2]) }) it('includes SEQUENCE in event exception (with recurrence-id)', () => { const mockEvent = { uid: 'event-exception-sequence', title: 'Event Exception with Sequence', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', allday: false, sequence: 5, recurrenceId: '2025-07-23T08:00:00Z', attendee: [] } const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) const [vevent] = result[2] const props = vevent[1] const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') expect(sequenceProp).toBeDefined() expect(sequenceProp).toEqual(['sequence', {}, 'integer', 5]) // Also verify recurrence-id is present const recurrenceIdProp = props.find( (p: VObjectProperty) => p[0] === 'recurrence-id' ) expect(recurrenceIdProp).toBeDefined() }) }) describe('combineMasterDateWithFormTime', () => { const mockFormatDateTime = (iso: string) => { const date = new Date(iso) const year = date.getUTCFullYear() const month = String(date.getUTCMonth() + 1).padStart(2, '0') const day = String(date.getUTCDate()).padStart(2, '0') const hour = String(date.getUTCHours()).padStart(2, '0') const minute = String(date.getUTCMinutes()).padStart(2, '0') return `${year}-${month}-${day}T${hour}:${minute}` } it('should use master event dates for all-day events', () => { const masterEvent = { start: '2025-10-14T07:00:00.000Z', end: '2025-10-14T08:00:00.000Z' } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, '2025-10-15T09:00', '2025-10-15T10:00', 'UTC', true, mockFormatDateTime ) // Function now returns ISO UTC strings for all-day events to avoid timezone offset issues expect(result.startDate).toBe('2025-10-14T00:00:00.000Z') expect(result.endDate).toBe('2025-10-14T00:00:00.000Z') }) it('should handle all-day events with missing end date', () => { const masterEvent = { start: '2025-10-14T07:00:00.000Z', end: undefined } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, '2025-10-15T09:00', '2025-10-15T10:00', 'UTC', true, mockFormatDateTime ) // Function now returns ISO UTC strings for all-day events to avoid timezone offset issues // When end is missing, it uses start date for end date expect(result.startDate).toBe('2025-10-14T00:00:00.000Z') expect(result.endDate).toBe('2025-10-14T00:00:00.000Z') }) it('should combine master date with form time for timed events', () => { const masterEvent = { start: '2025-10-14T07:00:00.000Z', end: '2025-10-14T08:00:00.000Z' } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, '2025-10-15T09:00', '2025-10-15T10:00', 'UTC', false, mockFormatDateTime ) const resultStartDate = new Date(result.startDate) expect(resultStartDate.getUTCDate()).toBe(14) // Monday (master's date) expect(resultStartDate.getUTCHours()).toBe(9) // 9am (form's time) }) it('should preserve timezone when combining date and time', () => { const masterEvent = { start: '2025-10-14T00:00:00.000Z', end: '2025-10-14T01:00:00.000Z' } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, '2025-10-15T14:30', '2025-10-15T15:30', 'Asia/Ho_Chi_Minh', false, mockFormatDateTime ) expect(result.startDate).toBeDefined() expect(result.endDate).toBeDefined() }) it('should handle timed events with missing end date', () => { const masterEvent = { start: '2025-10-14T07:00:00.000Z', end: undefined } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, '2025-10-15T09:00', '2025-10-15T10:00', 'UTC', false, mockFormatDateTime ) expect(result.startDate).toBeDefined() expect(result.endDate).toBeDefined() }) it('should handle different date formats from form input', () => { const masterEvent = { start: '2025-10-14T07:00:00.000Z', end: '2025-10-14T08:00:00.000Z' } as CalendarEvent // Test with different time format const result = combineMasterDateWithFormTime( masterEvent, '2025-10-15 09:00:00', '2025-10-15 10:00:00', 'UTC', false, mockFormatDateTime ) expect(result.startDate).toBeDefined() expect(result.endDate).toBeDefined() }) }) describe('normalizeRepetition', () => { it('should normalize repetition with all fields', () => { const repetition: RepetitionObject = { freq: 'weekly', interval: 2, byday: ['MO', 'WE', 'FR'], occurrences: 10, endDate: '2025-12-31' } const result = normalizeRepetition(repetition) expect(result).toEqual({ freq: 'weekly', interval: 2, byday: ['FR', 'MO', 'WE'], // Sorted occurrences: 10, endDate: '2025-12-31' }) }) it('should return null for empty repetition', () => { expect(normalizeRepetition(undefined)).toBeNull() expect(normalizeRepetition({} as RepetitionObject)).toBeNull() expect(normalizeRepetition({ freq: '' } as RepetitionObject)).toBeNull() }) it('should handle repetition with default interval', () => { const repetition: RepetitionObject = { freq: 'daily', occurrences: 5 } as RepetitionObject const result = normalizeRepetition(repetition) expect(result?.interval).toBe(1) }) it('should normalize empty byday to null', () => { const repetition: RepetitionObject = { freq: 'weekly', interval: 1, byday: [] } as RepetitionObject const result = normalizeRepetition(repetition) expect(result?.byday).toBeNull() }) }) describe('normalizeTimezone', () => { const mockResolveTimezone = (tz: string) => { if (tz === 'Asia/Saigon') return 'Asia/Ho_Chi_Minh' return tz } it('should resolve timezone aliases', () => { const result = normalizeTimezone('Asia/Saigon', mockResolveTimezone) expect(result).toBe('Asia/Ho_Chi_Minh') }) it('should return null for empty timezone', () => { expect(normalizeTimezone(undefined, mockResolveTimezone)).toBeNull() expect(normalizeTimezone(null, mockResolveTimezone)).toBeNull() expect(normalizeTimezone('', mockResolveTimezone)).toBeNull() }) it('should return same timezone if no alias', () => { const result = normalizeTimezone('UTC', mockResolveTimezone) expect(result).toBe('UTC') }) }) describe('detectRecurringEventChanges', () => { const mockResolveTimezone = (tz: string) => tz it('should detect when only time changes', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', end: '2025-10-14T08:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'daily', interval: 1 } as RepetitionObject, timezone: 'UTC', allday: false, start: '2025-10-15T09:00', end: '2025-10-15T10:00' }, null, mockResolveTimezone ) expect(result.timeChanged).toBe(true) expect(result.repetitionRulesChanged).toBe(true) }) it('should not detect time change when time is same', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', end: '2025-10-14T08:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'daily', interval: 1 } as RepetitionObject, timezone: 'UTC', allday: false, start: '2025-10-15T07:00', end: '2025-10-15T08:00' }, null, mockResolveTimezone ) expect(result.timeChanged).toBe(false) expect(result.repetitionRulesChanged).toBe(false) }) it('should detect timezone changes', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'daily', interval: 1 } as RepetitionObject, timezone: 'Asia/Ho_Chi_Minh', allday: false, start: '2025-10-15T07:00', end: '2025-10-15T08:00' }, null, mockResolveTimezone ) expect(result.timezoneChanged).toBe(true) expect(result.repetitionRulesChanged).toBe(true) }) it('should resolve timezone aliases before comparison', () => { const resolveWithAlias = (tz: string) => { if (tz === 'Asia/Saigon') return 'Asia/Ho_Chi_Minh' return tz } const oldEvent = { start: '2025-10-14T07:00:00.000Z', timezone: 'Asia/Saigon', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'daily', interval: 1 } as RepetitionObject, timezone: 'Asia/Ho_Chi_Minh', allday: false, start: '2025-10-15T07:00', end: '2025-10-15T08:00' }, null, resolveWithAlias ) expect(result.timezoneChanged).toBe(false) }) it('should detect frequency changes', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'weekly', interval: 1 } as RepetitionObject, timezone: 'UTC', allday: false, start: '2025-10-15T07:00', end: '2025-10-15T08:00' }, null, mockResolveTimezone ) expect(result.repetitionRulesChanged).toBe(true) }) it('should detect interval changes', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'daily', interval: 2 } as RepetitionObject, timezone: 'UTC', allday: false, start: '2025-10-15T07:00', end: '2025-10-15T08:00' }, null, mockResolveTimezone ) expect(result.repetitionRulesChanged).toBe(true) }) it('should detect byday changes', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'weekly', interval: 1, byday: ['MO', 'WE'] } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'weekly', interval: 1, byday: ['MO', 'FR'] } as RepetitionObject, timezone: 'UTC', allday: false, start: '2025-10-15T07:00', end: '2025-10-15T08:00' }, null, mockResolveTimezone ) expect(result.repetitionRulesChanged).toBe(true) }) it('should detect when multiple properties change', () => { const oldEvent = { start: '2025-10-14T07:00:00.000Z', timezone: 'UTC', allday: false, repetition: { freq: 'daily', interval: 1 } as RepetitionObject } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { freq: 'weekly', interval: 2 } as RepetitionObject, timezone: 'Asia/Ho_Chi_Minh', allday: true, start: '2025-10-15T09:00', end: '2025-10-15T10:00' }, null, mockResolveTimezone ) expect(result.timeChanged).toBe(true) expect(result.timezoneChanged).toBe(true) expect(result.repetitionRulesChanged).toBe(true) }) }) describe('parseCalendarEvent - delegated calendar URL handling', () => { const baseColor = { light: '#00FF00' } const rawData: VObjectProperty[] = [ ['uid', {}, 'text', 'event-uid-123'], ['dtstart', {}, 'date-time', '20240115T100000Z'], ['dtend', {}, 'date-time', '20240115T110000Z'], ['summary', {}, 'text', 'Test Event'] ] const eventURL = '/calendars/user2/cal1/event-uid-123.ics' describe('non-delegated calendar', () => { const calendar = { id: 'user1/cal1', delegated: false, link: '/calendars/user1/cal1.json' } as Calendar it('uses the raw eventURL as-is', () => { const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL) expect(result.URL).toBe(eventURL) }) }) describe('delegated calendar', () => { const calendar = { id: 'user2/cal1', delegated: true, link: '/calendars/user2/cal1.json', owner: { emails: ['owner@example.com'] } } as unknown as Calendar it('rewrites URL using calendar link base path', () => { const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL) expect(result.URL).toBe('/calendars/user2/cal1/event-uid-123.ics') }) it('preserves the event filename from the original URL', () => { const differentSourceURL = '/calendars/someother/path/event-uid-123.ics' const result = parseCalendarEvent( rawData, baseColor, calendar, differentSourceURL ) // filename is extracted and rebased onto calendar link expect(result.URL).toMatch( /\/calendars\/user2\/cal1\/event-uid-123\.ics$/ ) }) it('still sets calId from calendar.id', () => { const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL) expect(result.calId).toBe('user2/cal1') }) }) describe('empty calendar object (used in deleteEventInstance)', () => { it('does not throw and sets URL to empty string', () => { const result = parseCalendarEvent(rawData, baseColor, {} as Calendar, '') expect(result.URL).toBe('') expect(result.calId).toBeUndefined() }) }) })