diff --git a/defaultmodules/calendar/calendarfetcher.js b/defaultmodules/calendar/calendarfetcher.js index e746628512..67f27d0b53 100644 --- a/defaultmodules/calendar/calendarfetcher.js +++ b/defaultmodules/calendar/calendarfetcher.js @@ -51,6 +51,13 @@ class CalendarFetcher { */ async #handleResponse (response) { try { + // 304 Not Modified has no body: keep previously fetched events and just re-broadcast them. + if (response.status === 304) { + this.lastFetch = Date.now(); + this.broadcastEvents(); + return; + } + const responseData = await response.text(); const parsed = await ical.async.parseICS(responseData); diff --git a/defaultmodules/newsfeed/newsfeedfetcher.js b/defaultmodules/newsfeed/newsfeedfetcher.js index 1a5f421ab8..58963f7f6a 100644 --- a/defaultmodules/newsfeed/newsfeedfetcher.js +++ b/defaultmodules/newsfeed/newsfeedfetcher.js @@ -68,6 +68,12 @@ class NewsfeedFetcher { * @param {Response} response - The fetch Response object */ async #handleResponse (response) { + // 304 Not Modified has no body: keep previously fetched items and re-broadcast them. + if (response.status === 304) { + this.broadcastItems(); + return; + } + this.items = []; const parser = new FeedMe(); diff --git a/defaultmodules/weather/providers/buienradar.js b/defaultmodules/weather/providers/buienradar.js index c49c5fed50..ec4ebf5e62 100644 --- a/defaultmodules/weather/providers/buienradar.js +++ b/defaultmodules/weather/providers/buienradar.js @@ -111,6 +111,7 @@ class BuienradarProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/defaultmodules/weather/providers/envcanada.js b/defaultmodules/weather/providers/envcanada.js index d09715fc19..36a201928e 100644 --- a/defaultmodules/weather/providers/envcanada.js +++ b/defaultmodules/weather/providers/envcanada.js @@ -69,6 +69,7 @@ class EnvCanadaProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { // Check if hour changed - restart fetcher with new URL const newHour = new Date().toISOString().substring(11, 13); diff --git a/defaultmodules/weather/providers/openmeteo.js b/defaultmodules/weather/providers/openmeteo.js index ff02cc617e..e366e95b5e 100644 --- a/defaultmodules/weather/providers/openmeteo.js +++ b/defaultmodules/weather/providers/openmeteo.js @@ -172,6 +172,7 @@ class OpenMeteoProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/defaultmodules/weather/providers/openweathermap.js b/defaultmodules/weather/providers/openweathermap.js index a60ade013e..5dd44f9ca9 100644 --- a/defaultmodules/weather/providers/openweathermap.js +++ b/defaultmodules/weather/providers/openweathermap.js @@ -73,6 +73,7 @@ class OpenWeatherMapProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/defaultmodules/weather/providers/pirateweather.js b/defaultmodules/weather/providers/pirateweather.js index 7ea393d516..ec7fec6755 100644 --- a/defaultmodules/weather/providers/pirateweather.js +++ b/defaultmodules/weather/providers/pirateweather.js @@ -52,6 +52,7 @@ class PirateweatherProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/defaultmodules/weather/providers/smhi.js b/defaultmodules/weather/providers/smhi.js index 26b3673fb0..7490829898 100644 --- a/defaultmodules/weather/providers/smhi.js +++ b/defaultmodules/weather/providers/smhi.js @@ -95,6 +95,7 @@ class SMHIProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/defaultmodules/weather/providers/ukmetofficedatahub.js b/defaultmodules/weather/providers/ukmetofficedatahub.js index c0f75bc1ca..80cdba5a9f 100644 --- a/defaultmodules/weather/providers/ukmetofficedatahub.js +++ b/defaultmodules/weather/providers/ukmetofficedatahub.js @@ -64,6 +64,7 @@ class UkMetOfficeDataHubProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/defaultmodules/weather/providers/weatherflow.js b/defaultmodules/weather/providers/weatherflow.js index e94af5109f..55952ebcb3 100644 --- a/defaultmodules/weather/providers/weatherflow.js +++ b/defaultmodules/weather/providers/weatherflow.js @@ -74,6 +74,7 @@ class WeatherFlowProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); const processed = this.#processData(data); diff --git a/defaultmodules/weather/providers/weathergov.js b/defaultmodules/weather/providers/weathergov.js index 5abd314683..4269c0e811 100644 --- a/defaultmodules/weather/providers/weathergov.js +++ b/defaultmodules/weather/providers/weathergov.js @@ -207,6 +207,7 @@ class WeatherGovProvider { }); this.fetcher.on("response", async (response) => { + if (response.status === 304) return; try { const data = await response.json(); this.#handleResponse(data); diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_spec.js new file mode 100644 index 0000000000..e094db28b4 --- /dev/null +++ b/tests/unit/modules/default/calendar/calendar_fetcher_spec.js @@ -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 + }); + }); + }); +});