Skip to content

Commit 47ad98e

Browse files
fix(calendar): don't wipe events on HTTP 304 Not Modified
When a calendar server responds with 304 (nothing changed since last fetch), the response has no body. CalendarFetcher was parsing it anyway, which produced an empty event list - clearing all previously loaded events and showing nothing on screen. The bug was hidden until MagicMirrorOrg#4120 started forwarding 304 responses through HTTPFetcher instead of silently dropping them. Fix: skip parsing on 304 and re-broadcast the cached events as-is. Also adding unit tests for CalendarFetcher: 304 handling, HTTP error forwarding, parse error resilience, and shouldRefetch boundaries.
1 parent 58c2a5e commit 47ad98e

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

defaultmodules/calendar/calendarfetcher.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ class CalendarFetcher {
5151
*/
5252
async #handleResponse (response) {
5353
try {
54+
// 304 Not Modified has no body: keep previously fetched events and just re-broadcast them.
55+
if (response.status === 304) {
56+
this.lastFetch = Date.now();
57+
this.broadcastEvents();
58+
return;
59+
}
60+
5461
const responseData = await response.text();
5562
const parsed = await ical.async.parseICS(responseData);
5663

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
global.moment = require("moment-timezone");
2+
3+
const ical = require("node-ical");
4+
const moment = require("moment-timezone");
5+
const defaults = require("../../../../../js/defaults");
6+
7+
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
8+
9+
const CalendarFetcher = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcher`);
10+
11+
const makeFetcher = (options = {}) => new CalendarFetcher(
12+
options.url ?? "http://test.example.com/cal.ics",
13+
options.reloadInterval ?? 60000,
14+
options.excludedEvents ?? [],
15+
options.maximumEntries ?? 10,
16+
options.maximumNumberOfDays ?? 365,
17+
options.auth ?? null,
18+
options.includePastEvents ?? false,
19+
options.selfSignedCert ?? false
20+
);
21+
22+
// Triggers a fetch and resolves once the fetcher finishes (success or error).
23+
// On error, resolves with the errorInfo object so tests can inspect it.
24+
const emitResponse = (fetcher, response) => new Promise((resolve) => {
25+
fetcher.onReceive(resolve);
26+
fetcher.onError((_, errorInfo) => resolve(errorInfo));
27+
fetcher.httpFetcher.emit("response", response);
28+
});
29+
30+
const futureEventICS = () => {
31+
const start = moment().add(1, "hour");
32+
const end = moment().add(2, "hours");
33+
return [
34+
"BEGIN:VCALENDAR",
35+
"BEGIN:VEVENT",
36+
`DTSTART:${start.utc().format("YYYYMMDDTHHmmss")}Z`,
37+
`DTEND:${end.utc().format("YYYYMMDDTHHmmss")}Z`,
38+
"UID:future-1@test",
39+
"SUMMARY:Future Event",
40+
"END:VEVENT",
41+
"END:VCALENDAR"
42+
].join("\r\n");
43+
};
44+
45+
describe("CalendarFetcher", () => {
46+
afterEach(() => {
47+
vi.restoreAllMocks();
48+
});
49+
50+
describe("304 handling", () => {
51+
it("keeps previously fetched events when a 304 Not Modified response arrives", async () => {
52+
const fetcher = makeFetcher();
53+
54+
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
55+
expect(fetcher.events).toHaveLength(1);
56+
57+
// 304 Not Modified has an empty body: events must be preserved
58+
await emitResponse(fetcher, new Response(null, { status: 304 }));
59+
expect(fetcher.events).toHaveLength(1);
60+
});
61+
});
62+
63+
describe("error handling", () => {
64+
it("forwards HTTP fetch errors to onError callback", () => {
65+
const fetcher = makeFetcher();
66+
const onError = vi.fn();
67+
const errorInfo = { errorType: "NETWORK_ERROR", message: "boom" };
68+
69+
fetcher.onError(onError);
70+
fetcher.httpFetcher.emit("error", errorInfo);
71+
72+
expect(onError).toHaveBeenCalledWith(fetcher, errorInfo);
73+
});
74+
75+
it("keeps existing events and reports PARSE_ERROR when parsing fails", async () => {
76+
const fetcher = makeFetcher();
77+
78+
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
79+
expect(fetcher.events).toHaveLength(1);
80+
81+
vi.spyOn(ical.async, "parseICS").mockRejectedValueOnce(new Error("invalid ics"));
82+
const error = await emitResponse(fetcher, new Response("BROKEN", { status: 200 }));
83+
84+
expect(fetcher.events).toHaveLength(1);
85+
expect(error).toMatchObject({
86+
errorType: "PARSE_ERROR",
87+
translationKey: "MODULE_ERROR_UNSPECIFIED",
88+
url: "http://test.example.com/cal.ics"
89+
});
90+
});
91+
});
92+
93+
describe("delegation and refetch", () => {
94+
it("delegates fetchCalendar to HTTPFetcher.startPeriodicFetch", () => {
95+
const fetcher = makeFetcher();
96+
const startSpy = vi.spyOn(fetcher.httpFetcher, "startPeriodicFetch");
97+
98+
fetcher.fetchCalendar();
99+
100+
expect(startSpy).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it("shouldRefetch respects reload interval boundaries", () => {
104+
const fetcher = makeFetcher();
105+
106+
expect(fetcher.shouldRefetch()).toBe(true);
107+
108+
fetcher.lastFetch = Date.now() - 59999;
109+
expect(fetcher.shouldRefetch()).toBe(false);
110+
111+
fetcher.lastFetch = Date.now() - 60000;
112+
expect(fetcher.shouldRefetch()).toBe(true);
113+
});
114+
115+
it("passes configured filter options to CalendarFetcherUtils.filterEvents", async () => {
116+
const excludedEvents = ["Do not show me"];
117+
const filterSpy = vi.spyOn(CalendarFetcherUtils, "filterEvents");
118+
const fetcher = makeFetcher({ excludedEvents, maximumEntries: 7, maximumNumberOfDays: 30, includePastEvents: true });
119+
120+
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
121+
122+
expect(filterSpy).toHaveBeenCalledWith(expect.any(Object), {
123+
excludedEvents,
124+
includePastEvents: true,
125+
maximumEntries: 7,
126+
maximumNumberOfDays: 30
127+
});
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)