Files
workavia-calendar-front/__test__/features/Events/eventUtils.test.ts
T
lethemanh cadfa70e60 #708 apply strictier linting rules (#717)
* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
2026-04-01 17:15:10 +02:00

1489 lines
42 KiB
TypeScript

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()
})
})
})