chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled
Enforce Pull-Request Rules / check (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
global.moment = require("moment-timezone");
|
||||
|
||||
const ical = require("node-ical");
|
||||
const moment = require("moment-timezone");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
|
||||
|
||||
const CalendarFetcher = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcher`);
|
||||
|
||||
const makeFetcher = (options = {}) => new CalendarFetcher(
|
||||
options.url ?? "http://test.example.com/cal.ics",
|
||||
options.reloadInterval ?? 60000,
|
||||
options.excludedEvents ?? [],
|
||||
options.maximumEntries ?? 10,
|
||||
options.maximumNumberOfDays ?? 365,
|
||||
options.auth ?? null,
|
||||
options.includePastEvents ?? false,
|
||||
options.selfSignedCert ?? false
|
||||
);
|
||||
|
||||
// Triggers a fetch and resolves once the fetcher finishes (success or error).
|
||||
// On error, resolves with the errorInfo object so tests can inspect it.
|
||||
const emitResponse = (fetcher, response) => new Promise((resolve) => {
|
||||
fetcher.onReceive(resolve);
|
||||
fetcher.onError((_, errorInfo) => resolve(errorInfo));
|
||||
fetcher.httpFetcher.emit("response", response);
|
||||
});
|
||||
|
||||
const futureEventICS = () => {
|
||||
const start = moment().add(1, "hour");
|
||||
const end = moment().add(2, "hours");
|
||||
return [
|
||||
"BEGIN:VCALENDAR",
|
||||
"BEGIN:VEVENT",
|
||||
`DTSTART:${start.utc().format("YYYYMMDDTHHmmss")}Z`,
|
||||
`DTEND:${end.utc().format("YYYYMMDDTHHmmss")}Z`,
|
||||
"UID:future-1@test",
|
||||
"SUMMARY:Future Event",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
].join("\r\n");
|
||||
};
|
||||
|
||||
describe("CalendarFetcher", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("304 handling", () => {
|
||||
it("keeps previously fetched events when a 304 Not Modified response arrives", async () => {
|
||||
const fetcher = makeFetcher();
|
||||
|
||||
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
|
||||
expect(fetcher.events).toHaveLength(1);
|
||||
|
||||
// 304 Not Modified has an empty body: events must be preserved
|
||||
await emitResponse(fetcher, new Response(null, { status: 304 }));
|
||||
expect(fetcher.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("forwards HTTP fetch errors to onError callback", () => {
|
||||
const fetcher = makeFetcher();
|
||||
const onError = vi.fn();
|
||||
const errorInfo = { errorType: "NETWORK_ERROR", message: "boom" };
|
||||
|
||||
fetcher.onError(onError);
|
||||
fetcher.httpFetcher.emit("error", errorInfo);
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(fetcher, errorInfo);
|
||||
});
|
||||
|
||||
it("keeps existing events and reports PARSE_ERROR when parsing fails", async () => {
|
||||
const fetcher = makeFetcher();
|
||||
|
||||
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
|
||||
expect(fetcher.events).toHaveLength(1);
|
||||
|
||||
vi.spyOn(ical.async, "parseICS").mockRejectedValueOnce(new Error("invalid ics"));
|
||||
const error = await emitResponse(fetcher, new Response("BROKEN", { status: 200 }));
|
||||
|
||||
expect(fetcher.events).toHaveLength(1);
|
||||
expect(error).toMatchObject({
|
||||
errorType: "PARSE_ERROR",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED",
|
||||
url: "http://test.example.com/cal.ics"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegation and refetch", () => {
|
||||
it("delegates fetchCalendar to HTTPFetcher.startPeriodicFetch", () => {
|
||||
const fetcher = makeFetcher();
|
||||
const startSpy = vi.spyOn(fetcher.httpFetcher, "startPeriodicFetch");
|
||||
|
||||
fetcher.fetchCalendar();
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shouldRefetch respects reload interval boundaries", () => {
|
||||
const fetcher = makeFetcher();
|
||||
|
||||
expect(fetcher.shouldRefetch()).toBe(true);
|
||||
|
||||
fetcher.lastFetch = Date.now() - 59999;
|
||||
expect(fetcher.shouldRefetch()).toBe(false);
|
||||
|
||||
fetcher.lastFetch = Date.now() - 60000;
|
||||
expect(fetcher.shouldRefetch()).toBe(true);
|
||||
});
|
||||
|
||||
it("passes configured filter options to CalendarFetcherUtils.filterEvents", async () => {
|
||||
const excludedEvents = ["Do not show me"];
|
||||
const filterSpy = vi.spyOn(CalendarFetcherUtils, "filterEvents");
|
||||
const fetcher = makeFetcher({ excludedEvents, maximumEntries: 7, maximumNumberOfDays: 30, includePastEvents: true });
|
||||
|
||||
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
|
||||
|
||||
expect(filterSpy).toHaveBeenCalledWith(expect.any(Object), {
|
||||
excludedEvents,
|
||||
includePastEvents: true,
|
||||
maximumEntries: 7,
|
||||
maximumNumberOfDays: 30
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
global.moment = require("moment-timezone");
|
||||
|
||||
const defaults = require("../../../js/defaults");
|
||||
|
||||
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
|
||||
|
||||
describe("Calendar fetcher utils test", () => {
|
||||
const defaultConfig = {
|
||||
excludedEvents: []
|
||||
};
|
||||
|
||||
describe("filterEvents", () => {
|
||||
it("no events, not crash", () => {
|
||||
const base = moment().startOf("day").add(12, "hours");
|
||||
const minusOneHour = base.clone().subtract(1, "hours").toDate();
|
||||
const minusTwoHours = base.clone().subtract(2, "hours").toDate();
|
||||
const plusOneHour = base.clone().add(1, "hours").toDate();
|
||||
const plusTwoHours = base.clone().add(2, "hours").toDate();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" },
|
||||
ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" },
|
||||
upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" }
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
global.moment = require("moment-timezone");
|
||||
|
||||
const ical = require("node-ical");
|
||||
const moment = require("moment-timezone");
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
|
||||
|
||||
describe("Calendar fetcher utils test", () => {
|
||||
const defaultConfig = {
|
||||
excludedEvents: [],
|
||||
includePastEvents: false,
|
||||
maximumEntries: 10,
|
||||
maximumNumberOfDays: 367
|
||||
};
|
||||
|
||||
describe("filterEvents", () => {
|
||||
it("should return only ongoing and upcoming non full day events", () => {
|
||||
const minusOneHour = moment().subtract(1, "hours").toDate();
|
||||
const minusTwoHours = moment().subtract(2, "hours").toDate();
|
||||
const plusOneHour = moment().add(1, "hours").toDate();
|
||||
const plusTwoHours = moment().add(2, "hours").toDate();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" },
|
||||
ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" },
|
||||
upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" }
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(2);
|
||||
expect(filteredEvents[0].title).toBe("ongoingEvent");
|
||||
expect(filteredEvents[1].title).toBe("upcomingEvent");
|
||||
});
|
||||
|
||||
it("should return only ongoing and upcoming full day events", () => {
|
||||
const yesterday = moment().subtract(1, "days").startOf("day").toDate();
|
||||
const today = moment().startOf("day").toDate();
|
||||
const tomorrow = moment().add(1, "days").startOf("day").toDate();
|
||||
const dayAfterTomorrow = moment().add(2, "days").startOf("day").toDate();
|
||||
// Mark as DATE-only (full-day) events per ICS convention
|
||||
yesterday.dateOnly = true;
|
||||
today.dateOnly = true;
|
||||
tomorrow.dateOnly = true;
|
||||
dayAfterTomorrow.dateOnly = true;
|
||||
|
||||
// ICS convention: DTEND for a full-day event is the exclusive next day
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
pastEvent: { type: "VEVENT", start: yesterday, end: today, summary: "pastEvent" },
|
||||
ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" },
|
||||
upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" }
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(2);
|
||||
expect(filteredEvents[0].title).toBe("ongoingEvent");
|
||||
expect(filteredEvents[1].title).toBe("upcomingEvent");
|
||||
});
|
||||
|
||||
it("should hide excluded event with 'until' when far away and show it when close", () => {
|
||||
// An event ending in 10 days with until='3 days' should be hidden now
|
||||
const farStart = moment().add(9, "days").toDate();
|
||||
const farEnd = moment().add(10, "days").toDate();
|
||||
// An event ending in 1 day with until='3 days' should be shown (within 3 days of end)
|
||||
const closeStart = moment().add(1, "hours").toDate();
|
||||
const closeEnd = moment().add(1, "days").toDate();
|
||||
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
excludedEvents: [{ filterBy: "Payment", until: "3 days" }]
|
||||
};
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
farPayment: { type: "VEVENT", start: farStart, end: farEnd, summary: "Payment due" },
|
||||
closePayment: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Payment reminder" },
|
||||
normalEvent: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Normal event" }
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
// farPayment should be hidden (now < endDate - 3 days)
|
||||
// closePayment should show (now >= endDate - 3 days)
|
||||
// normalEvent should show (not matched by filter)
|
||||
const titles = filteredEvents.map((e) => e.title);
|
||||
expect(titles).not.toContain("Payment due");
|
||||
expect(titles).toContain("Payment reminder");
|
||||
expect(titles).toContain("Normal event");
|
||||
});
|
||||
|
||||
it("should fully exclude event when excludedEvents has no 'until'", () => {
|
||||
const start = moment().add(1, "hours").toDate();
|
||||
const end = moment().add(2, "hours").toDate();
|
||||
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
excludedEvents: ["Hidden"]
|
||||
};
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
hidden: { type: "VEVENT", start, end, summary: "Hidden event" },
|
||||
visible: { type: "VEVENT", start, end, summary: "Visible event" }
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
expect(filteredEvents[0].title).toBe("Visible event");
|
||||
});
|
||||
|
||||
it("should return the correct times when recurring events pass through daylight saving time", () => {
|
||||
const data = ical.parseICS(`BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Amsterdam:20250311T090000
|
||||
DTEND;TZID=Europe/Amsterdam:20250311T091500
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
|
||||
DTSTAMP:20250531T091103Z
|
||||
ORGANIZER;CN=test:mailto:test@test.com
|
||||
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
|
||||
CREATED:20230111T114612Z
|
||||
DESCRIPTION:Test
|
||||
LAST-MODIFIED:20250528T071312Z
|
||||
SEQUENCE:1
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Test
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT`);
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig);
|
||||
|
||||
const januaryFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "01-01");
|
||||
const julyFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "07-01");
|
||||
|
||||
let januaryMoment = moment(`${moment(januaryFirst[0].startDate, "x").format("YYYY")}-01-01T09:00:00`)
|
||||
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
|
||||
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
|
||||
|
||||
let julyMoment = moment(`${moment(julyFirst[0].startDate, "x").format("YYYY")}-07-01T09:00:00`)
|
||||
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
|
||||
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
|
||||
|
||||
expect(januaryFirst[0].startDate).toEqual(januaryMoment.format("x"));
|
||||
expect(julyFirst[0].startDate).toEqual(julyMoment.format("x"));
|
||||
});
|
||||
|
||||
it("should return the correct moments based on the timezone given", () => {
|
||||
const data = ical.parseICS(`BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Amsterdam:20250311T090000
|
||||
DTEND;TZID=Europe/Amsterdam:20250311T091500
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
|
||||
DTSTAMP:20250531T091103Z
|
||||
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
|
||||
SUMMARY:Test
|
||||
END:VEVENT`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days"));
|
||||
|
||||
const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01");
|
||||
const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01");
|
||||
|
||||
// The underlying timestamps must represent 09:00 Amsterdam time, regardless of local timezone
|
||||
expect(januaryFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+01:00");
|
||||
expect(julyFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+02:00");
|
||||
});
|
||||
|
||||
it("should return correct day-of-week for full-day recurring events across DST transitions", () => {
|
||||
// Test case for GitHub issue #3976: recurring full-day events showing on wrong day
|
||||
// This happens when DST transitions change the UTC offset between occurrences
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20251027
|
||||
DTEND;VALUE=DATE:20251028
|
||||
RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3
|
||||
DTSTAMP:20260103T123138Z
|
||||
UID:dst-test@google.com
|
||||
SUMMARY:Weekly Monday Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
// Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config)
|
||||
// This is how MagicMirror handles full-day events from calendars with timezones
|
||||
data["dst-test@google.com"].start.tz = "America/Chicago";
|
||||
|
||||
const pastMoment = moment("2025-10-01");
|
||||
const futureMoment = moment("2025-11-30");
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(data["dst-test@google.com"], pastMoment, futureMoment);
|
||||
const startMoments = instances.map((i) => i.startMoment);
|
||||
|
||||
// All occurrences should be on Monday (day() === 1) at midnight
|
||||
// Oct 27, 2025 - Before DST ends
|
||||
// Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix)
|
||||
// Nov 10, 2025 - After DST ends
|
||||
expect(startMoments).toHaveLength(3);
|
||||
expect(startMoments[0].day()).toBe(1); // Monday
|
||||
expect(startMoments[0].format("YYYY-MM-DD")).toBe("2025-10-27");
|
||||
expect(startMoments[0].hour()).toBe(0); // Midnight
|
||||
expect(startMoments[1].day()).toBe(1); // Monday (not Sunday!)
|
||||
expect(startMoments[1].format("YYYY-MM-DD")).toBe("2025-11-03");
|
||||
expect(startMoments[1].hour()).toBe(0); // Midnight
|
||||
expect(startMoments[2].day()).toBe(1); // Monday
|
||||
expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10");
|
||||
expect(startMoments[2].hour()).toBe(0); // Midnight
|
||||
});
|
||||
|
||||
it("should show Facebook birthday events in the current year, not in the birth year", () => {
|
||||
// Facebook birthday calendars use DTSTART with the actual birth year (e.g. 1990),
|
||||
// which previously caused rrule.js to return the wrong year occurrence.
|
||||
// With rrule-temporal this works correctly without any special-casing.
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:19900215
|
||||
RRULE:FREQ=YEARLY
|
||||
DTSTAMP:20260101T000000Z
|
||||
UID:birthday_123456789@facebook.com
|
||||
SUMMARY:Jane Doe's Birthday
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const thisYear = moment().year();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(data, {
|
||||
...defaultConfig,
|
||||
maximumNumberOfDays: 366
|
||||
});
|
||||
|
||||
const birthdayEvents = filteredEvents.filter((e) => e.title === "Jane Doe's Birthday");
|
||||
expect(birthdayEvents.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The event must expand to a recent year — NOT to the birth year 1990.
|
||||
// It should be the current or next year depending on whether Feb 15 has already passed.
|
||||
const startYear = moment(birthdayEvents[0].startDate, "x").year();
|
||||
expect(startYear).toBeGreaterThanOrEqual(thisYear);
|
||||
expect(startYear).toBeLessThanOrEqual(thisYear + 1);
|
||||
});
|
||||
|
||||
it("should produce a correctly shaped event object with all required fields", () => {
|
||||
const start = moment().add(1, "day").startOf("hour").toDate();
|
||||
const end = moment().add(1, "day").startOf("hour").add(1, "hour")
|
||||
.toDate();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
event1: {
|
||||
type: "VEVENT",
|
||||
start,
|
||||
end,
|
||||
summary: "Team Meeting",
|
||||
description: "Agenda TBD",
|
||||
location: "Room 42",
|
||||
geo: { lat: 52.52, lon: 13.4 },
|
||||
class: "PUBLIC",
|
||||
uid: "shaped-event@test"
|
||||
}
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
const ev = filteredEvents[0];
|
||||
expect(ev.title).toBe("Team Meeting");
|
||||
expect(ev.startDate).toBe(moment(start).format("x"));
|
||||
expect(ev.endDate).toBe(moment(end).format("x"));
|
||||
expect(ev.fullDayEvent).toBe(false);
|
||||
expect(ev.recurringEvent).toBe(false);
|
||||
expect(ev.class).toBe("PUBLIC");
|
||||
expect(ev.firstYear).toBe(moment(start).year());
|
||||
expect(ev.location).toBe("Room 42");
|
||||
expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 });
|
||||
expect(ev.description).toBe("Agenda TBD");
|
||||
});
|
||||
|
||||
it("should return correct firstYear for a full-day event on January 1st", () => {
|
||||
// node-ical creates DATE-only events with the local Date constructor: new Date(year, month, day).
|
||||
// getFullYear() on a locally-constructed date always returns the correct calendar year
|
||||
// regardless of the server's UTC offset — guard against regressions that switch to getUTCFullYear().
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:19900101
|
||||
DTEND;VALUE=DATE:19900102
|
||||
RRULE:FREQ=YEARLY
|
||||
UID:newyear-birthday@test
|
||||
SUMMARY:New Year Baby
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(data, {
|
||||
...defaultConfig,
|
||||
maximumNumberOfDays: 366
|
||||
});
|
||||
|
||||
const birthday = filteredEvents.find((e) => e.title === "New Year Baby");
|
||||
expect(birthday).toBeDefined();
|
||||
expect(birthday.firstYear).toBe(1990);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateFilterWindow", () => {
|
||||
it("ends maximumNumberOfDays after today's midnight", () => {
|
||||
const [, end] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
|
||||
|
||||
expect(end).toEqual(moment().startOf("day").add(30, "days").toDate());
|
||||
});
|
||||
|
||||
it("starts now when includePastEvents is false", () => {
|
||||
const before = Date.now();
|
||||
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
|
||||
const after = Date.now();
|
||||
|
||||
expect(start.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(start.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("starts maximumNumberOfDays before today's midnight when includePastEvents is true", () => {
|
||||
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: true, maximumNumberOfDays: 30 });
|
||||
|
||||
expect(start).toEqual(moment().startOf("day").subtract(30, "days").toDate());
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandRecurringEvent", () => {
|
||||
it("should extend end to end-of-day when event has no DTEND", () => {
|
||||
// node-ical sets end === start when DTEND is absent; our code extends to endOf("day")
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260222T100000Z
|
||||
UID:no-end-test@test
|
||||
SUMMARY:No End Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(data["no-end-test@test"], moment("2026-02-20"), moment("2026-02-24"));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59");
|
||||
});
|
||||
|
||||
it("should apply RECURRENCE-ID overrides (moved single occurrence)", () => {
|
||||
// A weekly event on Mondays at 10:00, but the second occurrence is moved to Tuesday 14:00
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260302T100000
|
||||
DTEND;TZID=Europe/Berlin:20260302T110000
|
||||
RRULE:FREQ=WEEKLY;COUNT=3
|
||||
UID:recurrence-override@test
|
||||
SUMMARY:Weekly Standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260310T140000
|
||||
DTEND;TZID=Europe/Berlin:20260310T150000
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260309T100000
|
||||
UID:recurrence-override@test
|
||||
SUMMARY:Moved Standup
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(
|
||||
data["recurrence-override@test"],
|
||||
moment("2026-03-01"),
|
||||
moment("2026-03-31")
|
||||
);
|
||||
|
||||
expect(instances).toHaveLength(3);
|
||||
|
||||
// First occurrence: Monday March 2, 10:00 (unchanged)
|
||||
expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-02 10:00");
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent(instances[0].event)).toBe("Weekly Standup");
|
||||
|
||||
// Second occurrence: moved to Tuesday March 10, 14:00
|
||||
expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-10 14:00");
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent(instances[1].event)).toBe("Moved Standup");
|
||||
|
||||
// Third occurrence: Monday March 16, 10:00 (unchanged)
|
||||
expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-16 10:00");
|
||||
});
|
||||
|
||||
it("should handle events with DURATION instead of DTEND", () => {
|
||||
// RFC 5545 allows DURATION as alternative to DTEND
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260315T090000Z
|
||||
DURATION:PT1H30M
|
||||
UID:duration-test@test
|
||||
SUMMARY:Duration Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(
|
||||
data["duration-test@test"],
|
||||
moment("2026-03-14"),
|
||||
moment("2026-03-16")
|
||||
);
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
// End should be 90 minutes after start
|
||||
const durationMinutes = instances[0].endMoment.diff(instances[0].startMoment, "minutes");
|
||||
expect(durationMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it("should handle recurring events with DURATION instead of DTEND", () => {
|
||||
const data = ical.parseICS(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260301T080000
|
||||
DURATION:PT45M
|
||||
RRULE:FREQ=DAILY;COUNT=3
|
||||
UID:recurring-duration@test
|
||||
SUMMARY:Daily Scrum
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
const instances = CalendarFetcherUtils.expandRecurringEvent(
|
||||
data["recurring-duration@test"],
|
||||
moment("2026-02-28"),
|
||||
moment("2026-03-05")
|
||||
);
|
||||
|
||||
expect(instances).toHaveLength(3);
|
||||
for (const inst of instances) {
|
||||
const durationMinutes = inst.endMoment.diff(inst.startMoment, "minutes");
|
||||
expect(durationMinutes).toBe(45);
|
||||
}
|
||||
expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-01");
|
||||
expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-02");
|
||||
expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-03");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterEvents error handling", () => {
|
||||
it("should skip a broken event but still return other valid events", () => {
|
||||
const start = moment().add(1, "hours").toDate();
|
||||
const end = moment().add(2, "hours").toDate();
|
||||
|
||||
vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => {
|
||||
throw new TypeError("invalid rrule");
|
||||
});
|
||||
|
||||
const result = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
brokenEvent: { type: "VEVENT", start, end, summary: "Broken" },
|
||||
goodEvent: { type: "VEVENT", start, end, summary: "Good" }
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe("Good");
|
||||
});
|
||||
|
||||
it("should let expandRecurringEvent throw through directly", () => {
|
||||
vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => {
|
||||
throw new TypeError("invalid rrule");
|
||||
});
|
||||
|
||||
const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" };
|
||||
expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapParameterValue", () => {
|
||||
it("should return the val of a ParameterValue object", () => {
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text");
|
||||
});
|
||||
|
||||
it("should return a plain string unchanged", () => {
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue("plain")).toBe("plain");
|
||||
});
|
||||
|
||||
it("should return falsy values unchanged", () => {
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue(undefined)).toBeUndefined();
|
||||
expect(CalendarFetcherUtils.unwrapParameterValue(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTitleFromEvent", () => {
|
||||
it("should return summary string directly", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ summary: "My Event" })).toBe("My Event");
|
||||
});
|
||||
|
||||
it("should unwrap ParameterValue summary", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ summary: { val: "My Event", params: {} } })).toBe("My Event");
|
||||
});
|
||||
|
||||
it("should fall back to description string", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ description: "Desc" })).toBe("Desc");
|
||||
});
|
||||
|
||||
it("should unwrap ParameterValue description as fallback title", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({ description: { val: "Desc", params: { LANGUAGE: "de" } } })).toBe("Desc");
|
||||
});
|
||||
|
||||
it("should return 'Event' when neither summary nor description is present", () => {
|
||||
expect(CalendarFetcherUtils.getTitleFromEvent({})).toBe("Event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterEvents with ParameterValue properties", () => {
|
||||
it("should handle DESCRIPTION;LANGUAGE=de and LOCATION;LANGUAGE=de without [object Object]", () => {
|
||||
const start = moment().add(1, "hours").toDate();
|
||||
const end = moment().add(2, "hours").toDate();
|
||||
|
||||
const filteredEvents = CalendarFetcherUtils.filterEvents(
|
||||
{
|
||||
event1: {
|
||||
type: "VEVENT",
|
||||
start,
|
||||
end,
|
||||
summary: "Test",
|
||||
description: { val: "Beschreibung", params: { LANGUAGE: "de" } },
|
||||
location: { val: "Berlin", params: { LANGUAGE: "de" } }
|
||||
}
|
||||
},
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
expect(filteredEvents[0].description).toBe("Beschreibung");
|
||||
expect(filteredEvents[0].location).toBe("Berlin");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
global.moment = require("moment");
|
||||
|
||||
const defaults = require("../../../../../js/defaults");
|
||||
|
||||
const CalendarUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarutils`);
|
||||
|
||||
describe("Calendar utils tests", () => {
|
||||
describe("capFirst", () => {
|
||||
const words = {
|
||||
rodrigo: "Rodrigo",
|
||||
"123m": "123m",
|
||||
"magic mirror": "Magic mirror",
|
||||
",a": ",a",
|
||||
ñandú: "Ñandú"
|
||||
};
|
||||
|
||||
Object.keys(words).forEach((word) => {
|
||||
it(`for '${word}' should return '${words[word]}'`, () => {
|
||||
expect(CalendarUtils.capFirst(word)).toBe(words[word]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not capitalize other letters", () => {
|
||||
expect(CalendarUtils.capFirst("event")).not.toBe("EVent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocaleSpecification", () => {
|
||||
it("should return a valid moment.LocaleSpecification for a 12-hour format", () => {
|
||||
expect(CalendarUtils.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } });
|
||||
});
|
||||
|
||||
it("should return a valid moment.LocaleSpecification for a 24-hour format", () => {
|
||||
expect(CalendarUtils.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } });
|
||||
});
|
||||
|
||||
it("should return the current system locale when called without timeFormat number", () => {
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } });
|
||||
});
|
||||
|
||||
it("should return a 12-hour longDateFormat when using the 'en' locale", () => {
|
||||
const localeBackup = moment.locale();
|
||||
moment.locale("en");
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
|
||||
moment.locale(localeBackup);
|
||||
});
|
||||
|
||||
it("should return a 12-hour longDateFormat when using the 'au' locale", () => {
|
||||
const localeBackup = moment.locale();
|
||||
moment.locale("au");
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
|
||||
moment.locale(localeBackup);
|
||||
});
|
||||
|
||||
it("should return a 12-hour longDateFormat when using the 'eg' locale", () => {
|
||||
const localeBackup = moment.locale();
|
||||
moment.locale("eg");
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
|
||||
moment.locale(localeBackup);
|
||||
});
|
||||
|
||||
it("should return a 24-hour longDateFormat when using the 'nl' locale", () => {
|
||||
const localeBackup = moment.locale();
|
||||
moment.locale("nl");
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
|
||||
moment.locale(localeBackup);
|
||||
});
|
||||
|
||||
it("should return a 24-hour longDateFormat when using the 'fr' locale", () => {
|
||||
const localeBackup = moment.locale();
|
||||
moment.locale("fr");
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
|
||||
moment.locale(localeBackup);
|
||||
});
|
||||
|
||||
it("should return a 24-hour longDateFormat when using the 'uk' locale", () => {
|
||||
const localeBackup = moment.locale();
|
||||
moment.locale("uk");
|
||||
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
|
||||
moment.locale(localeBackup);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shorten", () => {
|
||||
it("should not shorten if short enough", () => {
|
||||
expect(CalendarUtils.shorten("Event 1", 10, false, 1)).toBe("Event 1");
|
||||
});
|
||||
|
||||
it("should shorten into one line", () => {
|
||||
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 1)).toBe("Example …");
|
||||
});
|
||||
|
||||
it("should shorten into three lines", () => {
|
||||
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 3)).toBe("Example <br>event at 12 o <br>clock");
|
||||
});
|
||||
|
||||
it("should not shorten into three lines if wrap is false", () => {
|
||||
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, false, 3)).toBe("Example ev…");
|
||||
});
|
||||
|
||||
const strings = {
|
||||
" String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit…" },
|
||||
"long string that needs shortening": { length: 16, return: "long string that…" },
|
||||
"short string": { length: 16, return: "short string" },
|
||||
"long string with no maxLength defined": { return: "long string with no maxLength defined" }
|
||||
};
|
||||
|
||||
Object.keys(strings).forEach((string) => {
|
||||
it(`for '${string}' should return '${strings[string].return}'`, () => {
|
||||
expect(CalendarUtils.shorten(string, strings[string].length)).toBe(strings[string].return);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return an empty string if shorten is called with a non-string", () => {
|
||||
expect(CalendarUtils.shorten(100)).toBe("");
|
||||
});
|
||||
|
||||
it("should not shorten the string if shorten is called with a non-number maxLength", () => {
|
||||
expect(CalendarUtils.shorten("This is a test string", "This is not a number")).toBe("This is a test string");
|
||||
});
|
||||
|
||||
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", () => {
|
||||
expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe(
|
||||
"This is a <br>wrapEvent test. Should wrap <br>the string instead of <br>shorten it if called with <br>wrapEvent = true"
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", () => {
|
||||
expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe(
|
||||
"This is a wrapEvent <br>test. Should wrap the string <br>instead of shorten it if called <br>with wrapEvent = true"
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", () => {
|
||||
expect(CalendarUtils.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe(
|
||||
"This is a wrapEvent and <br>maxTitleLines test. Should wrap and …"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("titleTransform and shorten combined", () => {
|
||||
it("should replace the birthday and wrap nicely", () => {
|
||||
const transformedTitle = CalendarUtils.titleTransform("Michael Teeuw's birthday", [{ search: "'s birthday", replace: "" }]);
|
||||
expect(CalendarUtils.shorten(transformedTitle, 10, true, 2)).toBe("Michael <br>Teeuw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("titleTransform with yearmatchgroup", () => {
|
||||
it("should replace the birthday and wrap nicely", () => {
|
||||
const transformedTitle = CalendarUtils.titleTransform("Luciella '2000", [{ search: "^([^']*) '(\\d{4})$", replace: "$1 ($2.)", yearmatchgroup: 2 }]);
|
||||
const expectedResult = `Luciella (${new Date().getFullYear() - 2000}.)`;
|
||||
expect(transformedTitle).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user