375 lines
10 KiB
JavaScript
375 lines
10 KiB
JavaScript
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}`;
|
|
|
|
// Global test server instance
|
|
let server;
|
|
|
|
beforeAll((done) => {
|
|
// Clear the require cache to allow fresh import
|
|
jest.resetModules();
|
|
|
|
// Set a test port if not already set
|
|
if (!process.env.PORT) {
|
|
process.env.PORT = "3456";
|
|
}
|
|
|
|
// Dynamic require starts the server
|
|
server = require("../index.js");
|
|
// Give the server time to start
|
|
setTimeout(done, 200);
|
|
});
|
|
|
|
afterAll((done) => {
|
|
if (server) {
|
|
server.close(() => {
|
|
done();
|
|
});
|
|
}
|
|
});
|
|
|
|
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");
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toHaveProperty("ok", true);
|
|
expect(res.body).toHaveProperty("ts");
|
|
expect(res.body).toHaveProperty("version", "1.0.0");
|
|
|
|
// Validate timestamp is a valid ISO date string
|
|
expect(() => new Date(res.body.ts)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("POST /api/calendar/parse", () => {
|
|
test("should parse valid ICS content with events", async () => {
|
|
const validICS = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Test//Test//EN
|
|
BEGIN:VEVENT
|
|
DTSTART:20251230T140000
|
|
SUMMARY:Test Meeting
|
|
LOCATION:Graz Hbf
|
|
UID:test1@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
|
|
LOCATION:Vienna Hbf
|
|
UID:hasloc@example.com
|
|
END:VEVENT
|
|
END:VCALENDAR`;
|
|
|
|
const res = await request(BASE_URL)
|
|
.post("/api/calendar/parse")
|
|
.send(icsWithoutLocation);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
// Should only return events with locations
|
|
res.body.forEach(event => {
|
|
expect(event.destination).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test("should filter out past events", async () => {
|
|
const pastEventICS = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Test//Test//EN
|
|
BEGIN:VEVENT
|
|
DTSTART:20200101T100000
|
|
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);
|
|
|
|
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
|
|
VERSION:2.0
|
|
PRODID:-//Test//Test//EN
|
|
END:VCALENDAR`;
|
|
|
|
const res = await request(BASE_URL)
|
|
.post("/api/calendar/parse")
|
|
.send(emptyICS);
|
|
|
|
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 {{{";
|
|
|
|
const res = await request(BASE_URL)
|
|
.post("/api/calendar/parse")
|
|
.send(invalidICS);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toHaveProperty("error");
|
|
});
|
|
|
|
test("should clean location fields properly", async () => {
|
|
const icsWithVerboseLocation = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Test//Test//EN
|
|
BEGIN:VEVENT
|
|
DTSTART:20251230T140000
|
|
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);
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("GET /api/calendar", () => {
|
|
test("should return 400 when url parameter is missing", async () => {
|
|
const res = await request(BASE_URL).get("/api/calendar");
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toHaveProperty("error");
|
|
expect(res.body.error).toContain("url query parameter is required");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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"
|
|
}
|
|
}
|
|
};
|
|
|
|
const res = await request(BASE_URL)
|
|
.post("/api/hafas")
|
|
.send({ svcReqL: [hafasPayload] });
|
|
|
|
// The response depends on HAFAS API availability
|
|
// We mainly verify the proxy works
|
|
expect(res.statusCode).toBeLessThan(500);
|
|
});
|
|
|
|
test("should handle HAFAS API errors gracefully", async () => {
|
|
const invalidPayload = {
|
|
meth: "InvalidMethod",
|
|
req: {}
|
|
};
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Server Configuration", () => {
|
|
test("should use configured PORT from environment", () => {
|
|
const expectedPort = process.env.PORT || "3001";
|
|
expect(server.address().port).toBe(parseInt(expectedPort, 10));
|
|
});
|
|
|
|
test("should be 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")
|
|
);
|
|
|
|
const responses = await Promise.all(requests);
|
|
|
|
responses.forEach(res => {
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.ok).toBe(true);
|
|
});
|
|
});
|
|
});
|