22d674c6b5
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
1373 lines
39 KiB
TypeScript
1373 lines
39 KiB
TypeScript
import {
|
|
CalendarEvent,
|
|
RepetitionObject,
|
|
} from "../../../src/features/Events/EventsTypes";
|
|
import {
|
|
calendarEventToJCal,
|
|
parseCalendarEvent,
|
|
combineMasterDateWithFormTime,
|
|
normalizeRepetition,
|
|
normalizeTimezone,
|
|
detectRecurringEventChanges,
|
|
} from "../../../src/features/Events/eventUtils";
|
|
import { TIMEZONES } from "../../../src/utils/timezone-data";
|
|
|
|
describe("parseCalendarEvent", () => {
|
|
const baseColor = { light: "#00FF00" };
|
|
const calendarId = "calendar-123";
|
|
|
|
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 unknown as [string, Record<string, string>, string, any];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/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).toBe("Etc/UTC");
|
|
|
|
expect(result.organizer).toEqual({
|
|
cn: "Alice",
|
|
cal_address: "alice@example.com",
|
|
});
|
|
|
|
expect(result.attendee).toEqual([
|
|
{
|
|
cn: "Bob",
|
|
cal_address: "bob@example.com",
|
|
partstat: "ACCEPTED",
|
|
rsvp: "",
|
|
role: "",
|
|
cutype: "",
|
|
},
|
|
]);
|
|
});
|
|
|
|
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 unknown as [string, Record<string, string>, string, any];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/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).toBe("Etc/UTC");
|
|
|
|
expect(result.organizer).toEqual({
|
|
cn: "Alice",
|
|
cal_address: "alice@example.com",
|
|
});
|
|
|
|
expect(result.attendee).toEqual([
|
|
{
|
|
cn: "Bob",
|
|
cal_address: "bob@example.com",
|
|
partstat: "ACCEPTED",
|
|
rsvp: "",
|
|
role: "",
|
|
cutype: "",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("marks allday true for DATE format", () => {
|
|
const rawData = [
|
|
["UID", {}, "text", "event-2"],
|
|
["DTSTART", {}, "date", "2025-07-20"],
|
|
["DTEND", {}, "date", "2025-07-21"],
|
|
] as any;
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
|
|
expect(result.allday).toBe(true);
|
|
});
|
|
|
|
it("appends recurrence-id to UID if present", () => {
|
|
const rawData: any = [
|
|
["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,
|
|
calendarId,
|
|
"/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: any = [
|
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
|
];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawDataMissingUid,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
expect(result.error).toMatch(/missing crucial event param/);
|
|
|
|
const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]];
|
|
|
|
const result2 = parseCalendarEvent(
|
|
rawDataMissingStart,
|
|
baseColor,
|
|
calendarId,
|
|
"/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: any = [
|
|
["UID", {}, "text", "event-1"],
|
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00"],
|
|
["duration", {}, "duration", "PT60M"],
|
|
];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawDataMissing,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
expect(result.end).toBeDefined();
|
|
expect(result.timezone).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: any = [
|
|
["UID", {}, "text", "event-1"],
|
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
|
];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawDataMissing,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
expect(result.end).toBeDefined();
|
|
expect(result.timezone).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 = [
|
|
["UID", {}, "text", "event-5"],
|
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
|
] as any;
|
|
|
|
const valarm: any = [
|
|
"VALARM",
|
|
[
|
|
["ACTION", {}, "text", "DISPLAY"],
|
|
["TRIGGER", {}, "duration", "-PT15M"],
|
|
],
|
|
];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/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 unknown as [string, Record<string, string>, string, any];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
|
|
expect(result.attendee).toEqual([
|
|
{
|
|
cn: "",
|
|
cal_address: "john@example.com",
|
|
partstat: "",
|
|
rsvp: "",
|
|
role: "",
|
|
cutype: "",
|
|
},
|
|
]);
|
|
|
|
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 unknown as [string, Record<string, string>, string, any];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/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 unknown as [string, Record<string, string>, string, any];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
|
|
expect(result.start).toBe("2025-07-18T09:00:00Z");
|
|
expect(result.end).toBe("2025-07-18T10:00:00Z");
|
|
expect(result.timezone).toBe("Etc/UTC");
|
|
});
|
|
|
|
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 unknown as [string, Record<string, string>, string, any];
|
|
|
|
const result = parseCalendarEvent(
|
|
rawData,
|
|
baseColor,
|
|
calendarId,
|
|
"/calendars/test.ics"
|
|
);
|
|
|
|
expect(result.allday).toBe(true);
|
|
expect(result.start).toBe("2025-07-18");
|
|
expect(result.end).toBe("2025-07-19");
|
|
});
|
|
});
|
|
|
|
describe("calendarEventToJCal", () => {
|
|
beforeAll(() => {
|
|
jest.mock("ical.js", () => ({
|
|
Timezone: jest.fn().mockImplementation(({ component, tzid }) => ({
|
|
component: {
|
|
jCal: [`vtimezone`, [], [["tzid", {}, "text", tzid]]],
|
|
},
|
|
})),
|
|
}));
|
|
});
|
|
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: any[]) => p[0] === "dtstart");
|
|
const dtend = props.find((p: any[]) => 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: any = {
|
|
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: [],
|
|
};
|
|
|
|
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 adjusting dtend", () => {
|
|
const mockEvent: any = {
|
|
uid: "event-11",
|
|
title: "All Day",
|
|
start: "2025-07-21T00:00:00.000Z",
|
|
end: "2025-07-21T00:00:00.000Z",
|
|
timezone: "Europe/Paris",
|
|
allday: true,
|
|
attendee: [],
|
|
};
|
|
|
|
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-23T00: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: any[]) => 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: any[]) => 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: any[]) => 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: any[]) => p[0] === "sequence");
|
|
expect(sequenceProp).toBeDefined();
|
|
expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]);
|
|
|
|
// Also verify recurrence-id is present
|
|
const recurrenceIdProp = props.find((p: any[]) => p[0] === "recurrence-id");
|
|
expect(recurrenceIdProp).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("combineMasterDateWithFormTime", () => {
|
|
const mockFormatDateTime = (iso: string, tz: 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;
|
|
const mockFormatDateTime = (iso: string, tz: string) => {
|
|
const date = new Date(iso);
|
|
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
return `2025-10-14T${hour}:${minute}`;
|
|
};
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
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,
|
|
mockFormatDateTime
|
|
);
|
|
|
|
expect(result.timeChanged).toBe(true);
|
|
expect(result.timezoneChanged).toBe(true);
|
|
expect(result.repetitionRulesChanged).toBe(true);
|
|
});
|
|
});
|