Files
MagicMirror/tests/unit/functions/http_fetcher_spec.js

472 lines
12 KiB
JavaScript
Raw Permalink Normal View History

[core] refactor: extract and centralize HTTP fetcher (#4016) ## Summary PR [#3976](https://github.com/MagicMirrorOrg/MagicMirror/pull/3976) introduced smart HTTP error handling for the Calendar module. This PR extracts that HTTP logic into a central `HTTPFetcher` class. Calendar is the first module to use it. Follow-up PRs would migrate Newsfeed and maybe even Weather. **Before this change:** - ❌ Each module had to implemented its own `fetch()` calls - ❌ No centralized retry logic or backoff strategies - ❌ No timeout handling for hanging requests - ❌ Error detection relied on fragile string parsing **What this PR adds:** - ✅ Unified HTTPFetcher class with intelligent retry strategies - ✅ Modern AbortController with configurable timeout (default 30s) - ✅ Proper undici Agent for self-signed certificates - ✅ Structured error objects with translation keys - ✅ Calendar module migrated as first consumer - ✅ Comprehensive unit tests with msw (Mock Service Worker) ## Architecture **Before - Decentralized HTTP handling:** ``` Calendar Module Newsfeed Module Weather Module ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ fetch() own │ │ fetch() own │ │ fetch() own │ │ retry logic │ │ basic error │ │ no retry │ │ error parse │ │ handling │ │ client-side │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └───────────────────────┴───────────────────────┘ ▼ External APIs ``` **After - Centralized with HTTPFetcher:** ``` ┌─────────────────────────────────────────────────────┐ │ HTTPFetcher │ │ • Unified retry strategies (401/403, 429, 5xx) │ │ • AbortController timeout (30s) │ │ • Structured errors with translation keys │ │ • undici Agent for self-signed certs │ └────────────┬──────────────┬──────────────┬──────────┘ │ │ │ ┌───────▼───────┐ ┌────▼─────┐ ┌──────▼──────┐ │ Calendar │ │ Newsfeed │ │ Weather │ │ ✅ This PR │ │ future │ │ future │ └───────────────┘ └──────────┘ └─────────────┘ │ │ │ └──────────────┴──────────────┘ ▼ External APIs ``` ## Complexity Considerations **Does HTTPFetcher add complexity?** Even if it may look more complex, it actually **reduces overall complexity**: - **Calendar already has this logic** (PR #3976) - we're extracting, not adding - **Alternative is worse:** Each module implementing own logic = 3× the code - **Better testability:** 443 lines of tests once vs. duplicating tests for each module - **Standards-based:** Retry-After is RFC 7231, not custom logic ## Future Benefits **Weather migration (future PR):** Moving Weather from client-side to server-side will enable: - **Same robust error handling** - Weather gets 429 rate-limiting, 5xx backoff for free - **Simpler architecture** - No proxy layer needed Moving the weather modules from client-side to server-side will be a big undertaking, but I think it's a good strategy. Even if we only move the calendar and newsfeed to the new HTTP fetcher and leave the weather as it is, this PR still makes sense, I think. ## Breaking Changes **None** ---- I am eager to hear your opinion on this :slightly_smiling_face:
2026-01-22 19:24:37 +01:00
const { http, HttpResponse } = require("msw");
const { setupServer } = require("msw/node");
const HTTPFetcher = require("#http_fetcher");
const TEST_URL = "http://test.example.com/data";
let server;
let fetcher;
beforeAll(() => {
server = setupServer();
server.listen({ onUnhandledRequest: "error" });
});
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
if (fetcher) {
fetcher.clearTimer();
fetcher = null;
}
});
describe("HTTPFetcher", () => {
describe("Basic fetch operations", () => {
it("should emit response event on successful fetch", async () => {
const responseData = "test data";
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text(responseData);
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", (response) => {
resolve(response);
});
});
fetcher.startPeriodicFetch();
const response = await responsePromise;
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe(responseData);
});
it("should emit error event on network failure", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR");
expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION");
expect(errorInfo).toHaveProperty("url", TEST_URL);
});
it("should emit error event on timeout", async () => {
server.use(
http.get(TEST_URL, async () => {
// Simulate a slow server that never responds
await new Promise((resolve) => setTimeout(resolve, 60000));
return HttpResponse.text("too late");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
expect(errorInfo.message).toContain("timeout");
expect(errorInfo.message).toContain("100ms");
});
});
describe("HTTPFetcher - HTTP status code handling", () => {
describe("401/403 errors (Auth failures)", () => {
it("should emit error with AUTH_FAILURE for 401", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 401 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(401);
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED");
});
it("should emit error with AUTH_FAILURE for 403", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 403 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(403);
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
});
});
describe("429 errors (Rate limiting)", () => {
it("should emit error with RATE_LIMITED for 429", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, {
status: 429,
headers: { "Retry-After": "120" }
});
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(429);
expect(errorInfo.errorType).toBe("RATE_LIMITED");
expect(errorInfo.retryAfter).toBeGreaterThan(0);
});
it("should parse Retry-After header in seconds", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, {
status: 429,
headers: { "Retry-After": "300" }
});
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
// 300 seconds = 300000 ms
expect(errorInfo.retryAfter).toBe(300000);
});
});
describe("5xx errors (Server errors)", () => {
it("should emit error with SERVER_ERROR for 500", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 500 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(500);
expect(errorInfo.errorType).toBe("SERVER_ERROR");
});
it("should emit error with SERVER_ERROR for 503", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 503 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(503);
expect(errorInfo.errorType).toBe("SERVER_ERROR");
});
});
describe("4xx errors (Client errors)", () => {
it("should emit error with CLIENT_ERROR for 404", async () => {
server.use(
http.get(TEST_URL, () => {
return new HttpResponse(null, { status: 404 });
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", (errorInfo) => {
resolve(errorInfo);
});
});
fetcher.startPeriodicFetch();
const errorInfo = await errorPromise;
expect(errorInfo.status).toBe(404);
expect(errorInfo.errorType).toBe("CLIENT_ERROR");
});
});
});
});
describe("HTTPFetcher - Authentication", () => {
it("should include Basic auth header when configured", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
auth: {
method: "basic",
user: "testuser",
pass: "testpass"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`;
expect(receivedHeaders.authorization).toBe(expectedAuth);
});
it("should include Bearer auth header when configured", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
auth: {
method: "bearer",
pass: "my-token-123"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
expect(receivedHeaders.authorization).toBe("Bearer my-token-123");
});
});
describe("Custom headers", () => {
it("should include custom headers in request", async () => {
let receivedHeaders = null;
server.use(
http.get(TEST_URL, ({ request }) => {
receivedHeaders = Object.fromEntries(request.headers);
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
headers: {
"X-Custom-Header": "custom-value",
Accept: "application/json"
}
});
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
expect(receivedHeaders["x-custom-header"]).toBe("custom-value");
expect(receivedHeaders.accept).toBe("application/json");
});
});
describe("Timer management", () => {
it("should not set timer in test mode", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text("ok");
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
fetcher.startPeriodicFetch();
await responsePromise;
// Timer should NOT be set in test mode (mmTestMode=true)
expect(fetcher.reloadTimer).toBeNull();
});
it("should clear timer when clearTimer is called", () => {
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
// Manually set a timer to test clearing
fetcher.reloadTimer = setTimeout(() => {}, 10000);
expect(fetcher.reloadTimer).not.toBeNull();
fetcher.clearTimer();
expect(fetcher.reloadTimer).toBeNull();
});
});
describe("fetch() method", () => {
it("should emit response event when called", async () => {
const responseData = "direct fetch data";
server.use(
http.get(TEST_URL, () => {
return HttpResponse.text(responseData);
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const responsePromise = new Promise((resolve) => {
fetcher.on("response", resolve);
});
await fetcher.fetch();
const response = await responsePromise;
expect(response.ok).toBe(true);
const text = await response.text();
expect(text).toBe(responseData);
});
it("should emit error event on network error", async () => {
server.use(
http.get(TEST_URL, () => {
return HttpResponse.error();
})
);
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
const errorPromise = new Promise((resolve) => {
fetcher.on("error", resolve);
});
await fetcher.fetch();
const errorInfo = await errorPromise;
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
});
});
describe("selfSignedCert dispatcher", () => {
const { Agent } = require("undici");
it("should set rejectUnauthorized=false when selfSignedCert is true", () => {
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
selfSignedCert: true
});
const options = fetcher.getRequestOptions();
expect(options.dispatcher).toBeInstanceOf(Agent);
const agentOptionsSymbol = Object.getOwnPropertySymbols(options.dispatcher).find((s) => s.description === "options");
const dispatcherOptions = options.dispatcher[agentOptionsSymbol];
expect(dispatcherOptions.connect.rejectUnauthorized).toBe(false);
});
it("should not set a dispatcher when selfSignedCert is false", () => {
fetcher = new HTTPFetcher(TEST_URL, {
reloadInterval: 60000,
selfSignedCert: false
});
const options = fetcher.getRequestOptions();
expect(options.dispatcher).toBeUndefined();
});
});