From 4133d0e9c8bb54085cd4e9be97147920314c3796 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 09:52:24 +0200 Subject: [PATCH] Refactor endpoint tests for reliability and isolation - Set fixed test port before requiring server - Mock global fetch to avoid external network calls - Implement server readiness check via health endpoint - Add explicit mock assertions for HAFAS proxy tests - Split large test block into focused describe sections --- server/__tests__/endpoints.test.js | 681 +++++++++++++++++------------ 1 file changed, 392 insertions(+), 289 deletions(-) diff --git a/server/__tests__/endpoints.test.js b/server/__tests__/endpoints.test.js index b33a3a8..0226f0d 100644 --- a/server/__tests__/endpoints.test.js +++ b/server/__tests__/endpoints.test.js @@ -1,372 +1,475 @@ const request = require("supertest"); -const http = require("http"); -// The server starts automatically when required. -// We'll test against the actual running server on the configured port. -const PORT = process.env.PORT || 3001; -const BASE_URL = `http://localhost:${PORT}`; +// ── Setup ── +// Set a test port before requiring the server module +const TEST_PORT = 3501; +process.env.PORT = String(TEST_PORT); -// Global test server instance +// Mock global fetch before requiring server (for HAFAS proxy tests) +let mockFetch; let server; -beforeAll((done) => { - // Clear the require cache to allow fresh import - jest.resetModules(); +beforeAll(async () => { + mockFetch = jest.fn(); + global.fetch = mockFetch; - // Set a test port if not already set - if (!process.env.PORT) { - process.env.PORT = "3456"; - } - - // Dynamic require starts the server + // Require server after setting PORT and mocking fetch server = require("../index.js"); - // Give the server time to start - setTimeout(done, 200); + + // Wait for server to be ready by polling health endpoint + await waitForServer(); }); afterAll((done) => { if (server) { - server.close(() => { - done(); - }); + server.close(done); } + jest.restoreAllMocks(); }); -describe("ÖBB Planner Server Endpoints", () => { - describe("GET /api/health", () => { - test("should return health status with ok: true", async () => { - const res = await request(BASE_URL).get("/api/health"); +const BASE = `http://localhost:${TEST_PORT}`; - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty("ok", true); - expect(res.body).toHaveProperty("ts"); - expect(res.body).toHaveProperty("version", "1.0.0"); +async function waitForServer(maxAttempts = 20) { + for (let i = 0; i < maxAttempts; i++) { + try { + await fetch(`${BASE}/api/health`); + return; // Server is ready + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error("Server did not start in time"); +} - // Validate timestamp is a valid ISO date string - expect(() => new Date(res.body.ts)).not.toThrow(); +// ── Health Endpoint ── + +describe("GET /api/health", () => { + test("returns ok: true with timestamp and version", async () => { + const res = await request(BASE).get("/api/health"); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body).toHaveProperty("ts"); + expect(res.body).toHaveProperty("version", "1.0.0"); + expect(new Date(res.body.ts)).toBeInstanceOf(Date); + expect(() => new Date(res.body.ts)).not.toThrow(); + }); +}); + +// ── Calendar Parse Endpoint ── + +describe("POST /api/calendar/parse", () => { + const futureDate = () => { + const d = new Date(Date.now() + 2 * 24 * 3600000); + return d.toISOString().replace(/[-T:]/g, "").slice(0, 8) + "T120000"; + }; + + const pastDate = "20200101T100000"; + + const icsWithEvents = (overrides = "") => `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:${futureDate()} +SUMMARY:Test Meeting +LOCATION:Graz Hbf +UID:test-1@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART:${futureDate()} +SUMMARY:Another Event +LOCATION:Salzburg Hbf, 5020 Salzburg +UID:test-2@example.com +END:VEVENT +END:VCALENDAR`; + + test("parses valid ICS with multiple events", async () => { + const res = await request(BASE).post("/api/calendar/parse").send(icsWithEvents()); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBe(2); + }); + + test("returns properly structured event objects", async () => { + const res = await request(BASE).post("/api/calendar/parse").send(icsWithEvents()); + + res.body.forEach((event) => { + expect(event).toHaveProperty("id"); + expect(event).toHaveProperty("title"); + expect(event).toHaveProperty("destination"); + expect(event).toHaveProperty("eventTime"); + expect(event.source).toBe("calendar"); }); }); - describe("POST /api/calendar/parse", () => { - test("should parse valid ICS content with events", async () => { - const validICS = `BEGIN:VCALENDAR + test("filters out events without location field", async () => { + const icsNoLoc = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN BEGIN:VEVENT -DTSTART:20251230T140000 -SUMMARY:Test Meeting -LOCATION:Graz Hbf -UID:test1@example.com +DTSTART:${futureDate()} +SUMMARY:No Location Event +UID:no-loc@example.com END:VEVENT BEGIN:VEVENT -DTSTART:20251231T100000 -SUMMARY:Another Meeting -LOCATION:Salzburg Hauptbahnhof, 5020 Salzburg -UID:test2@example.com -END:VEVENT -END:VCALENDAR`; - - const res = await request(BASE_URL) - .post("/api/calendar/parse") - .send(validICS); - - expect(res.statusCode).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - expect(res.body.length).toBeGreaterThanOrEqual(1); - - // Check event structure - res.body.forEach(event => { - expect(event).toHaveProperty("id"); - expect(event).toHaveProperty("title"); - expect(event).toHaveProperty("destination"); - expect(event).toHaveProperty("eventTime"); - expect(event).toHaveProperty("source", "calendar"); - }); - }); - - test("should filter out events without location", async () => { - const icsWithoutLocation = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -BEGIN:VEVENT -DTSTART:20251230T140000 -SUMMARY:Event without location -UID:noloc@example.com -END:VEVENT -BEGIN:VEVENT -DTSTART:20251231T100000 -SUMMARY:Event with location +DTSTART:${futureDate()} +SUMMARY:Has Location Event LOCATION:Vienna Hbf -UID:hasloc@example.com +UID:has-loc@example.com END:VEVENT END:VCALENDAR`; - const res = await request(BASE_URL) - .post("/api/calendar/parse") - .send(icsWithoutLocation); + const res = await request(BASE).post("/api/calendar/parse").send(icsNoLoc); - expect(res.statusCode).toBe(200); - // Should only return events with locations - res.body.forEach(event => { - expect(event.destination).toBeTruthy(); - }); - }); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0].title).toBe("Has Location Event"); + }); - test("should filter out past events", async () => { - const pastEventICS = `BEGIN:VCALENDAR + test("filters out past events", async () => { + const icsPast = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN BEGIN:VEVENT -DTSTART:20200101T100000 +DTSTART:${pastDate} SUMMARY:Old Event LOCATION:Graz Hbf UID:old@example.com END:VEVENT END:VCALENDAR`; - const res = await request(BASE_URL) - .post("/api/calendar/parse") - .send(pastEventICS); + const res = await request(BASE).post("/api/calendar/parse").send(icsPast); - expect(res.statusCode).toBe(200); - expect(res.body).toEqual([]); - }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); - test("should return empty array for ICS with no VEVENT entries", async () => { - const emptyICS = `BEGIN:VCALENDAR + test("returns empty array for ICS with no VEVENT entries", async () => { + const emptyICS = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN END:VCALENDAR`; - const res = await request(BASE_URL) - .post("/api/calendar/parse") - .send(emptyICS); + const res = await request(BASE).post("/api/calendar/parse").send(emptyICS); - expect(res.statusCode).toBe(200); - expect(res.body).toEqual([]); - }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); - test("should handle invalid ICS content with 400 error", async () => { - const invalidICS = "This is not valid ICS content {{{"; + test("returns 400 for invalid ICS content", async () => { + const res = await request(BASE).post("/api/calendar/parse").send("This is not valid ICS content {{{"); - const res = await request(BASE_URL) - .post("/api/calendar/parse") - .send(invalidICS); + expect(res.statusCode).toBe(400); + expect(res.body).toHaveProperty("error"); + expect(res.body.error).toContain("Invalid ICS"); + }); - expect(res.statusCode).toBe(400); - expect(res.body).toHaveProperty("error"); - }); - - test("should clean location fields properly", async () => { - const icsWithVerboseLocation = `BEGIN:VCALENDAR + test("cleans verbose location fields", async () => { + const icsVerbose = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN BEGIN:VEVENT -DTSTART:20251230T140000 +DTSTART:${futureDate()} SUMMARY:Meeting LOCATION:Graz Hauptbahnhof, Europaplatz 5, 8020 Graz UID:verbose@example.com END:VEVENT END:VCALENDAR`; - const res = await request(BASE_URL) - .post("/api/calendar/parse") - .send(icsWithVerboseLocation); + const res = await request(BASE).post("/api/calendar/parse").send(icsVerbose); - expect(res.statusCode).toBe(200); - if (res.body.length > 0) { - // Location should be trimmed to just "Graz Hauptbahnhof" - expect(res.body[0].destination).toBe("Graz Hauptbahnhof"); - } - }); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0].destination).toBe("Graz Hauptbahnhof"); }); - describe("GET /api/calendar", () => { - test("should return 400 when url parameter is missing", async () => { - const res = await request(BASE_URL).get("/api/calendar"); + test("handles ICS with escaped newlines in location", async () => { + const icsNewlines = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:${futureDate()} +SUMMARY:Meeting +LOCATION:Graz Hbf\\nEuropaplatz 5\\n8020 Graz +UID:nl@example.com +END:VEVENT +END:VCALENDAR`; - expect(res.statusCode).toBe(400); - expect(res.body).toHaveProperty("error"); - expect(res.body.error).toContain("url query parameter is required"); - }); + const res = await request(BASE).post("/api/calendar/parse").send(icsNewlines); - test("should handle invalid URL gracefully", async () => { - const res = await request(BASE_URL) - .get("/api/calendar") - .query({ url: "http://invalid-host-that-does-not-exist.example.com/calendar.ics" }); - - // Should return 502 for unreachable hosts - expect(res.statusCode).toBe(502); - expect(res.body).toHaveProperty("error"); - }); - - test("should handle webcal:// protocol", async () => { - // Just verify it doesn't crash with webcal protocol - const res = await request(BASE_URL) - .get("/api/calendar") - .query({ url: "webcal://invalid-host.example.com/calendar.ics" }); - - // May fail to connect but shouldn't crash - expect([400, 502]).toContain(res.statusCode); - }); - - test("should accept days query parameter", async () => { - const res = await request(BASE_URL) - .get("/api/calendar") - .query({ - url: "http://invalid.example.com/cal.ics", - days: 30 - }); - - // Connection will fail but endpoint should accept the parameter - expect(res.statusCode).toBe(502); - }); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0].destination).toBe("Graz Hbf"); }); - describe("POST /api/hafas", () => { - test("should forward request to HAFAS API", async () => { - const hafasPayload = { - meth: "LocMatch", - req: { - input: { - loc: { type: "S", name: "Graz Hbf?" }, - maxLoc: 1, - field: "S" - } - } - }; + test("sorts events by eventTime ascending", async () => { + const d1 = new Date(Date.now() + 3 * 24 * 3600000); + const d2 = new Date(Date.now() + 2 * 24 * 3600000); + const d3 = new Date(Date.now() + 4 * 24 * 3600000); - const res = await request(BASE_URL) - .post("/api/hafas") - .send({ svcReqL: [hafasPayload] }); + const fmt = (d) => d.toISOString().replace(/[-T:]/g, "").slice(0, 8) + "T120000"; - // The response depends on HAFAS API availability - // We mainly verify the proxy works - expect(res.statusCode).toBeLessThan(500); - }); + const icsUnsorted = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:${fmt(d1)} +SUMMARY:Later +LOCATION:Graz Hbf +UID:later@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART:${fmt(d2)} +SUMMARY:Sooner +LOCATION:Vienna Hbf +UID:sooner@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART:${fmt(d3)} +SUMMARY:Latest +LOCATION:Salzburg Hbf +UID:latest@example.com +END:VEVENT +END:VCALENDAR`; - test("should handle HAFAS API errors gracefully", async () => { - const invalidPayload = { - meth: "InvalidMethod", - req: {} - }; + const res = await request(BASE).post("/api/calendar/parse").send(icsUnsorted); - const res = await request(BASE_URL) - .post("/api/hafas") - .send({ svcReqL: [invalidPayload] }); - - // May get various responses depending on HAFAS behavior - expect(res.statusCode).toBeLessThan(500); - }); - - test("should handle large payloads within limit", async () => { - // Create a payload that's within the 5mb limit - const largePayload = { - meth: "TripSearch", - req: { - depLocL: [{ type: "S", extId: "8000263" }], - arrLocL: [{ type: "S", extId: "8000197" }], - outDate: "20251230", - outTime: "100000", - outFrwd: true, - numF: 10, - maxChg: 3 - } - }; - - const res = await request(BASE_URL) - .post("/api/hafas") - .send({ svcReqL: [largePayload] }); - - expect(res.statusCode).toBeLessThan(500); - }); - - test("should respect timeout for slow HAFAS responses", async () => { - // This test verifies the timeout mechanism exists - // We can't easily test slow responses without mocking fetch - - const res = await request(BASE_URL) - .post("/api/hafas") - .send({ svcReqL: [] }); - - // Should not hang indefinitely - expect(res.statusCode).toBeLessThan(500); - }); - }); - - describe("Middleware and Error Handling", () => { - test("should handle JSON parsing errors for malformed JSON", async () => { - const res = await request(BASE_URL) - .post("/api/hafas") - .set("Content-Type", "application/json") - .send("{ invalid json }"); - - expect(res.statusCode).toBe(400); - }); - - test("should support CORS headers", async () => { - const res = await request(BASE_URL) - .get("/api/health") - .set("Origin", "http://example.com"); - - expect(res.header["access-control-allow-origin"]).toBe("*"); - }); - - test("should handle OPTIONS preflight requests", async () => { - const res = await request(BASE_URL) - .options("/api/health"); - - expect(res.statusCode).toBe(204); - expect(res.header["access-control-allow-origin"]).toBeDefined(); - }); - - test("should reject oversized payloads", async () => { - // Create a payload larger than 5mb - const largePayload = JSON.stringify({ - data: "x".repeat(6 * 1024 * 1024) // 6mb of data - }); - - const res = await request(BASE_URL) - .post("/api/hafas") - .send(largePayload); - - expect(res.statusCode).toBe(413); - }); - }); - - describe("Route Not Found", () => { - test("should return 404 for non-existent routes", async () => { - const res = await request(BASE_URL).get("/api/nonexistent"); - - expect(res.statusCode).toBe(404); - }); - - test("should return 405 for wrong HTTP method", async () => { - const res = await request(BASE_URL).get("/api/hafas"); - - expect(res.statusCode).toBe(405); - }); + expect(res.body.length).toBe(3); + expect(res.body[0].title).toBe("Sooner"); + expect(res.body[1].title).toBe("Later"); + expect(res.body[2].title).toBe("Latest"); }); }); -describe("Server Configuration", () => { - test("should use configured PORT from environment", () => { - const expectedPort = process.env.PORT || "3001"; - expect(server.address().port).toBe(parseInt(expectedPort, 10)); +// ── Calendar URL Endpoint ── + +describe("GET /api/calendar", () => { + test("returns 400 when url query parameter is missing", async () => { + const res = await request(BASE).get("/api/calendar"); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("url query parameter is required"); }); - test("should be listening and accepting connections", () => { + test("handles unreachable host gracefully with 502", async () => { + const res = await request(BASE).get("/api/calendar").query({ url: "http://192.0.2.1/nonexistent.ics" }); // TEST-NET-1, unreachable + + expect(res.statusCode).toBe(502); + expect(res.body).toHaveProperty("error"); + }); + + test("accepts webcal:// protocol without crashing", async () => { + const res = await request(BASE).get("/api/calendar").query({ url: "webcal://192.0.2.1/cal.ics" }); + + // Connection will fail but endpoint should handle it gracefully + expect([400, 502]).toContain(res.statusCode); + }); + + test("accepts days query parameter", async () => { + const res = await request(BASE).get("/api/calendar").query({ + url: "http://192.0.2.1/cal.ics", + days: 30, + }); + + expect(res.statusCode).toBe(502); + }); +}); + +// ── HAFAS Proxy Endpoint ── + +describe("POST /api/hafas", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("forwards request to ÖBB HAFAS API", async () => { + const mockResponse = { + svcResL: [ + { + res: { + match: { loc: { name: "Graz Hbf", extId: "8000263" } }, + }, + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockResponse), + }); + + const payload = { + svcReqL: [ + { + meth: "LocMatch", + req: { + input: { + loc: { type: "S", name: "Graz Hbf?" }, + maxLoc: 3, + field: "S", + }, + }, + }, + ], + }; + + const res = await request(BASE).post("/api/hafas").send(payload); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test("forwards request with correct headers and body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + + const payload = { svcReqL: [{ meth: "TestMethod", req: {} }] }; + + await request(BASE).post("/api/hafas").send(payload); + + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toBe("https://fahrplan.oebb.at/bin/mgate.exe"); + expect(fetchCall[1].method).toBe("POST"); + expect(fetchCall[1].headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(fetchCall[1].body)).toMatchObject(payload); + }); + + test("returns 502 when HAFAS upstream returns error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const res = await request(BASE).post("/api/hafas").send({ svcReqL: [] }); + + expect(res.statusCode).toBe(502); + expect(res.body.error).toContain("ÖBB returned HTTP 500"); + }); + + test("returns 502 when HAFAS upstream throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const res = await request(BASE).post("/api/hafas").send({ svcReqL: [] }); + + expect(res.statusCode).toBe(502); + expect(res.body.error).toContain("Network error"); + }); + + test("respects timeout for slow responses", async () => { + // Simulate a response that takes longer than 12 seconds + mockFetch.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 13000))); + + const startTime = Date.now(); + const res = await request(BASE).post("/api/hafas").send({ svcReqL: [] }); + + const elapsed = Date.now() - startTime; + // Should timeout before 13 seconds + expect(elapsed).toBeLessThan(13000); + expect(res.statusCode).toBe(502); + }); + + test("handles empty svcReqL array", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ svcResL: [] }), + }); + + const res = await request(BASE).post("/api/hafas").send({ svcReqL: [] }); + + expect(res.statusCode).toBe(200); + }); +}); + +// ── Middleware & CORS ── + +describe("Middleware and CORS", () => { + test("allows CORS from any origin", async () => { + const res = await request(BASE).get("/api/health").set("Origin", "https://example.com"); + + expect(res.header["access-control-allow-origin"]).toBe("*"); + }); + + test("handles OPTIONS preflight requests", async () => { + const res = await request(BASE) + .options("/api/health") + .set("Origin", "https://example.com") + .set("Access-Control-Request-Method", "GET"); + + expect(res.statusCode).toBe(204); + }); + + test("rejects malformed JSON with 400", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + const res = await request(BASE) + .post("/api/hafas") + .set("Content-Type", "application/json") + .send("{ not valid json }"); + + expect(res.statusCode).toBe(400); + }); + + test("rejects oversized payloads with 413", async () => { + const largePayload = JSON.stringify({ + data: "x".repeat(6 * 1024 * 1024), // 6MB + }); + + const res = await request(BASE).post("/api/hafas").send(largePayload); + + expect(res.statusCode).toBe(413); + }); +}); + +// ── Route Handling ── + +describe("Route Handling", () => { + test("returns 404 for non-existent routes", async () => { + const res = await request(BASE).get("/api/nonexistent"); + + expect(res.statusCode).toBe(404); + }); + + test("returns 405 for wrong HTTP method on hafas", async () => { + const res = await request(BASE).get("/api/hafas"); + + expect(res.statusCode).toBe(405); + }); + + test("returns 405 for wrong HTTP method on calendar parse", async () => { + const res = await request(BASE).get("/api/calendar/parse"); + + expect(res.statusCode).toBe(405); + }); +}); + +// ── Server Configuration ── + +describe("Server Configuration", () => { + test("uses configured PORT from environment", () => { + expect(server.address().port).toBe(TEST_PORT); + }); + + test("is listening and accepting connections", () => { expect(server.listening).toBe(true); }); - test("should handle concurrent requests", async () => { - const requests = Array.from({ length: 10 }, () => - request(BASE_URL).get("/api/health") - ); + test("handles multiple concurrent requests", async () => { + const requests = Array.from({ length: 10 }, () => request(BASE).get("/api/health")); const responses = await Promise.all(requests); - responses.forEach(res => { + expect(responses.length).toBe(10); + responses.forEach((res) => { expect(res.statusCode).toBe(200); expect(res.body.ok).toBe(true); });