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
This commit is contained in:
+392
-289
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user