Files
time_to_leave/server/__tests__/endpoints.test.js
T
fegger 4133d0e9c8 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
2026-05-09 09:52:24 +02:00

478 lines
13 KiB
JavaScript

const request = require("supertest");
// ── Setup ──
// Set a test port before requiring the server module
const TEST_PORT = 3501;
process.env.PORT = String(TEST_PORT);
// Mock global fetch before requiring server (for HAFAS proxy tests)
let mockFetch;
let server;
beforeAll(async () => {
mockFetch = jest.fn();
global.fetch = mockFetch;
// Require server after setting PORT and mocking fetch
server = require("../index.js");
// Wait for server to be ready by polling health endpoint
await waitForServer();
});
afterAll((done) => {
if (server) {
server.close(done);
}
jest.restoreAllMocks();
});
const BASE = `http://localhost:${TEST_PORT}`;
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");
}
// ── 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");
});
});
test("filters out events without location field", async () => {
const icsNoLoc = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART:${futureDate()}
SUMMARY:No Location Event
UID:no-loc@example.com
END:VEVENT
BEGIN:VEVENT
DTSTART:${futureDate()}
SUMMARY:Has Location Event
LOCATION:Vienna Hbf
UID:has-loc@example.com
END:VEVENT
END:VCALENDAR`;
const res = await request(BASE).post("/api/calendar/parse").send(icsNoLoc);
expect(res.statusCode).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].title).toBe("Has Location Event");
});
test("filters out past events", async () => {
const icsPast = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART:${pastDate}
SUMMARY:Old Event
LOCATION:Graz Hbf
UID:old@example.com
END:VEVENT
END:VCALENDAR`;
const res = await request(BASE).post("/api/calendar/parse").send(icsPast);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual([]);
});
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).post("/api/calendar/parse").send(emptyICS);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual([]);
});
test("returns 400 for invalid ICS content", async () => {
const res = await request(BASE).post("/api/calendar/parse").send("This is not valid ICS content {{{");
expect(res.statusCode).toBe(400);
expect(res.body).toHaveProperty("error");
expect(res.body.error).toContain("Invalid ICS");
});
test("cleans verbose location fields", async () => {
const icsVerbose = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART:${futureDate()}
SUMMARY:Meeting
LOCATION:Graz Hauptbahnhof, Europaplatz 5, 8020 Graz
UID:verbose@example.com
END:VEVENT
END:VCALENDAR`;
const res = await request(BASE).post("/api/calendar/parse").send(icsVerbose);
expect(res.statusCode).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].destination).toBe("Graz Hauptbahnhof");
});
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`;
const res = await request(BASE).post("/api/calendar/parse").send(icsNewlines);
expect(res.statusCode).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].destination).toBe("Graz Hbf");
});
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 fmt = (d) => d.toISOString().replace(/[-T:]/g, "").slice(0, 8) + "T120000";
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`;
const res = await request(BASE).post("/api/calendar/parse").send(icsUnsorted);
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");
});
});
// ── 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("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("handles multiple concurrent requests", async () => {
const requests = Array.from({ length: 10 }, () => request(BASE).get("/api/health"));
const responses = await Promise.all(requests);
expect(responses.length).toBe(10);
responses.forEach((res) => {
expect(res.statusCode).toBe(200);
expect(res.body.ok).toBe(true);
});
});
});