Skip to content

Commit f689031

Browse files
test: add tests for CalendarFetcher.handleHttpError and fetch methods
1 parent e19703a commit f689031

1 file changed

Lines changed: 314 additions & 0 deletions

File tree

test/calendar-fetcher.test.js

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
const assert = require("node:assert/strict");
2+
const {afterEach, beforeEach, describe, it, mock} = require("node:test");
3+
const Module = require("module");
4+
5+
// ---------------------------------------------------------------------------
6+
// Mock 'logger' – CalendarFetcher does require("logger") which only resolves
7+
// inside the MagicMirror runtime. We intercept Module._load to return a no-op.
8+
// ---------------------------------------------------------------------------
9+
const loggerMock = {
10+
log: mock.fn(),
11+
warn: mock.fn(),
12+
error: mock.fn(),
13+
debug: mock.fn(),
14+
info: mock.fn()
15+
};
16+
17+
/* eslint-disable no-underscore-dangle */
18+
const originalLoad = Module._load.bind(Module);
19+
Module._load = (request, parent, isMain) => {
20+
if (request === "logger") {
21+
return loggerMock;
22+
}
23+
24+
return originalLoad(request, parent, isMain);
25+
};
26+
/* eslint-enable no-underscore-dangle */
27+
28+
const CalendarFetcher = require("../lib/calendar-fetcher");
29+
30+
// ---------------------------------------------------------------------------
31+
// Helpers
32+
// ---------------------------------------------------------------------------
33+
34+
/**
35+
* Build a minimal CalendarFetcher whose callbacks are recorded.
36+
*/
37+
const makeFetcher = (overrides = {}) => {
38+
const onSuccess = mock.fn();
39+
const onError = mock.fn();
40+
41+
const instance = new CalendarFetcher(
42+
"https://example.com/cal.ics",
43+
60_000,
44+
null,
45+
{
46+
userAgent: "TestAgent/1.0",
47+
authFailureCooldown: 7_200_000, // 2 h
48+
rateLimitCooldown: 900_000, // 15 min
49+
clientErrorCooldown: 3_600_000, // 1 h
50+
onSuccess,
51+
onError,
52+
...overrides
53+
}
54+
);
55+
56+
return {instance,
57+
onSuccess,
58+
onError};
59+
};
60+
61+
/**
62+
* Build a minimal Response-like object for handleHttpError.
63+
*/
64+
const makeResponse = (status, statusText = "Error", retryAfter = null) => ({
65+
status,
66+
statusText,
67+
headers: {
68+
get: (name) => (name.toLowerCase() === "retry-after" ? retryAfter : null)
69+
}
70+
});
71+
72+
// ---------------------------------------------------------------------------
73+
// handleHttpError – pure method, no network involved
74+
// ---------------------------------------------------------------------------
75+
76+
describe("CalendarFetcher.handleHttpError", () => {
77+
it("401 → uses authFailureCooldown and sets suspendUntil", () => {
78+
const {instance} = makeFetcher();
79+
const before = Date.now();
80+
const result = instance.handleHttpError(makeResponse(401, "Unauthorized"));
81+
82+
assert.ok(result.error instanceof Error);
83+
assert.match(result.error.message, /401/u);
84+
assert.ok(instance.suspendUntil >= before + 7_200_000 - 50);
85+
assert.equal(result.delay, Math.max(7_200_000, 60_000));
86+
});
87+
88+
it("403 → uses authFailureCooldown", () => {
89+
const {instance} = makeFetcher();
90+
const result = instance.handleHttpError(makeResponse(403, "Forbidden"));
91+
92+
assert.ok(result.error instanceof Error);
93+
assert.equal(result.delay, 7_200_000);
94+
assert.equal(instance.suspendReason, "auth error (403)");
95+
});
96+
97+
it("429 without Retry-After → uses rateLimitCooldown", () => {
98+
const {instance} = makeFetcher();
99+
const result = instance.handleHttpError(makeResponse(429, "Too Many Requests"));
100+
101+
assert.equal(result.delay, Math.max(900_000, 60_000));
102+
assert.equal(instance.suspendReason, "rate limit");
103+
});
104+
105+
it("429 with Retry-After as seconds number → uses that duration", () => {
106+
const {instance} = makeFetcher();
107+
const result = instance.handleHttpError(makeResponse(429, "Too Many Requests", "120"));
108+
109+
assert.equal(result.delay, Math.max(120_000, 60_000));
110+
});
111+
112+
it("429 with Retry-After as future date string → computes remaining ms", () => {
113+
const {instance} = makeFetcher();
114+
const futureDate = new Date(Date.now() + 300_000).toUTCString(); // 5 min
115+
const result = instance.handleHttpError(makeResponse(429, "Too Many Requests", futureDate));
116+
117+
// Should be close to 300 000 ms (±500 ms for test execution)
118+
assert.ok(result.delay >= 299_000);
119+
assert.ok(result.delay <= 301_000);
120+
});
121+
122+
it("429 with Retry-After as past date → falls back to rateLimitCooldown (max 0 guard)", () => {
123+
const {instance} = makeFetcher();
124+
const pastDate = new Date(Date.now() - 60_000).toUTCString();
125+
const result = instance.handleHttpError(makeResponse(429, "Too Many Requests", pastDate));
126+
127+
// Math.max(0, pastDate - now) = 0 → cooldown stays at 0, delay = max(0, reloadInterval)
128+
assert.equal(result.delay, Math.max(0, 60_000));
129+
});
130+
131+
it("404 → uses clientErrorCooldown", () => {
132+
const {instance} = makeFetcher();
133+
const result = instance.handleHttpError(makeResponse(404, "Not Found"));
134+
135+
assert.equal(result.delay, Math.max(3_600_000, 60_000));
136+
assert.match(instance.suspendReason, /client error/u);
137+
});
138+
139+
it("408 → uses clientErrorCooldown", () => {
140+
const {instance} = makeFetcher();
141+
const result = instance.handleHttpError(makeResponse(408, "Request Timeout"));
142+
143+
assert.equal(result.delay, 3_600_000);
144+
});
145+
146+
it("500 → no suspend, delay = reloadInterval", () => {
147+
const {instance} = makeFetcher();
148+
const result = instance.handleHttpError(makeResponse(500, "Internal Server Error"));
149+
150+
assert.equal(instance.suspendUntil, null);
151+
assert.equal(result.delay, 60_000);
152+
});
153+
154+
it("503 → no suspend, delay = reloadInterval", () => {
155+
const {instance} = makeFetcher();
156+
const result = instance.handleHttpError(makeResponse(503, "Service Unavailable"));
157+
158+
assert.equal(instance.suspendUntil, null);
159+
assert.equal(result.delay, 60_000);
160+
});
161+
162+
it("unexpected status (302) → no suspend, delay = reloadInterval", () => {
163+
const {instance} = makeFetcher();
164+
const result = instance.handleHttpError(makeResponse(302, "Found"));
165+
166+
assert.equal(instance.suspendUntil, null);
167+
assert.equal(result.delay, 60_000);
168+
});
169+
});
170+
171+
// ---------------------------------------------------------------------------
172+
// fetch() – uses mocked global.fetch
173+
// ---------------------------------------------------------------------------
174+
175+
describe("CalendarFetcher.fetch", () => {
176+
let originalFetch;
177+
178+
beforeEach(() => {
179+
originalFetch = global.fetch;
180+
loggerMock.log.mock.resetCalls();
181+
loggerMock.warn.mock.resetCalls();
182+
loggerMock.error.mock.resetCalls();
183+
});
184+
185+
afterEach(() => {
186+
global.fetch = originalFetch;
187+
});
188+
189+
it("calls onSuccess with response text on HTTP 200", async () => {
190+
const {instance, onSuccess, onError} = makeFetcher();
191+
instance.stop(); // prevent automatic retry timer
192+
193+
global.fetch = mock.fn(() => ({
194+
ok: true,
195+
text: () => "BEGIN:VCALENDAR\nEND:VCALENDAR"
196+
}));
197+
198+
await instance.fetch();
199+
instance.stop();
200+
201+
assert.equal(onSuccess.mock.calls.length, 1);
202+
assert.equal(onSuccess.mock.calls[0].arguments[0], "BEGIN:VCALENDAR\nEND:VCALENDAR");
203+
assert.equal(onError.mock.calls.length, 0);
204+
});
205+
206+
it("calls onError on HTTP 500", async () => {
207+
const {instance, onSuccess, onError} = makeFetcher();
208+
209+
global.fetch = mock.fn(() => ({
210+
ok: false,
211+
status: 500,
212+
statusText: "Internal Server Error",
213+
headers: {get: () => null}
214+
}));
215+
216+
await instance.fetch();
217+
instance.stop();
218+
219+
assert.equal(onError.mock.calls.length, 1);
220+
assert.ok(onError.mock.calls[0].arguments[0] instanceof Error);
221+
assert.equal(onSuccess.mock.calls.length, 0);
222+
});
223+
224+
it("calls onError on network-level failure", async () => {
225+
const {instance, onSuccess, onError} = makeFetcher();
226+
227+
global.fetch = mock.fn(() => {
228+
throw new Error("ECONNREFUSED");
229+
});
230+
231+
await instance.fetch();
232+
instance.stop();
233+
234+
assert.equal(onError.mock.calls.length, 1);
235+
assert.match(onError.mock.calls[0].arguments[0].message, /ECONNREFUSED/u);
236+
assert.equal(onSuccess.mock.calls.length, 0);
237+
});
238+
239+
it("skips fetch and reschedules when suspended", async () => {
240+
const {instance, onSuccess, onError} = makeFetcher();
241+
242+
instance.suspendUntil = Date.now() + 60_000;
243+
instance.suspendReason = "auth error (401)";
244+
245+
const fetchSpy = mock.fn(() => ({ok: true,
246+
text: () => ""}));
247+
global.fetch = fetchSpy;
248+
249+
await instance.fetch();
250+
instance.stop();
251+
252+
assert.equal(fetchSpy.mock.calls.length, 0);
253+
assert.equal(onSuccess.mock.calls.length, 0);
254+
assert.equal(onError.mock.calls.length, 0);
255+
});
256+
257+
it("clears suspension after suspend period has passed", async () => {
258+
const {instance, onSuccess} = makeFetcher();
259+
260+
// suspend in the past
261+
instance.suspendUntil = Date.now() - 1;
262+
instance.suspendReason = "auth error (401)";
263+
264+
global.fetch = mock.fn(() => ({
265+
ok: true,
266+
text: () => "BEGIN:VCALENDAR\nEND:VCALENDAR"
267+
}));
268+
269+
await instance.fetch();
270+
instance.stop();
271+
272+
assert.equal(instance.suspendUntil, null);
273+
assert.equal(instance.suspendReason, null);
274+
assert.equal(onSuccess.mock.calls.length, 1);
275+
});
276+
277+
it("sets basic auth header when auth method is basic", async () => {
278+
const {instance} = makeFetcher();
279+
280+
let capturedHeaders;
281+
global.fetch = mock.fn((url, opts) => {
282+
capturedHeaders = opts.headers;
283+
return {ok: true,
284+
text: () => ""};
285+
});
286+
287+
instance.auth = {method: "basic",
288+
user: "alice",
289+
pass: "s3cr3t"};
290+
await instance.fetch();
291+
instance.stop();
292+
293+
const expected = `Basic ${Buffer.from("alice:s3cr3t").toString("base64")}`;
294+
assert.equal(capturedHeaders.Authorization, expected);
295+
});
296+
297+
it("sets Bearer auth header when auth method is bearer", async () => {
298+
const {instance} = makeFetcher();
299+
300+
let capturedHeaders;
301+
global.fetch = mock.fn((url, opts) => {
302+
capturedHeaders = opts.headers;
303+
return {ok: true,
304+
text: () => ""};
305+
});
306+
307+
instance.auth = {method: "bearer",
308+
pass: "mytoken"};
309+
await instance.fetch();
310+
instance.stop();
311+
312+
assert.equal(capturedHeaders.Authorization, "Bearer mytoken");
313+
});
314+
});

0 commit comments

Comments
 (0)