initial commit

This commit is contained in:
2026-05-09 09:30:52 +02:00
commit 8e02656431
7367 changed files with 732863 additions and 0 deletions
+374
View File
@@ -0,0 +1,374 @@
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);
});
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* Tests for server helper functions
*
* Since the helper functions (extractEvents, cleanLocation) are not exported
* from index.js, we reproduce them here for thorough unit testing.
*/
// ── Reproduced helpers (mirror of server/index.js) ──
function cleanLocation(loc) {
if (!loc) return "";
const firstLine = loc.split(/\\n|\r\n|\n/)[0].trim();
const parts = firstLine.split(",").map(s => s.trim()).filter(Boolean);
return (parts[0] || firstLine).substring(0, 120);
}
function extractEvents(icalData, horizonDays = 14) {
const now = new Date();
const horizon = new Date(now.getTime() + horizonDays * 86_400_000);
return Object.values(icalData)
.filter(e => e.type === "VEVENT")
.filter(e => {
if (!e.start) return false;
const start = new Date(e.start);
return start > now && start < horizon;
})
.filter(e => e.location)
.map(e => ({
id: String(e.uid || `ical-${Date.now()}-${Math.random()}`),
title: (e.summary || "Event").trim(),
destination: cleanLocation(String(e.location)),
eventTime: new Date(e.start).toISOString(),
source: "calendar",
}))
.sort((a, b) => new Date(a.eventTime) - new Date(b.eventTime));
}
// ── Tests ──
describe("cleanLocation", () => {
test("returns empty string for falsy input", () => {
expect(cleanLocation(null)).toBe("");
expect(cleanLocation(undefined)).toBe("");
expect(cleanLocation("")).toBe("");
});
test("returns simple location unchanged", () => {
expect(cleanLocation("Graz Hbf")).toBe("Graz Hbf");
expect(cleanLocation("Salzburg Hauptbahnhof")).toBe("Salzburg Hauptbahnhof");
});
test("extracts first segment before comma", () => {
expect(cleanLocation("Graz Hbf, Europaplatz 5, 8020 Graz")).toBe("Graz Hbf");
expect(cleanLocation("Vienna Airport, Terminal 3, 1300 Wien")).toBe("Vienna Airport");
});
test("handles escaped newlines", () => {
expect(cleanLocation("Graz Hbf\\nEuropaplatz 5\\n8020 Graz")).toBe("Graz Hbf");
});
test("handles actual newlines", () => {
expect(cleanLocation("Graz Hbf\nEuropaplatz 5\n8020 Graz")).toBe("Graz Hbf");
});
test("handles Windows-style newlines", () => {
expect(cleanLocation("Graz Hbf\r\nEuropaplatz 5\r\n8020 Graz")).toBe("Graz Hbf");
});
test("truncates to 120 characters", () => {
const longName = "A".repeat(130);
expect(cleanLocation(longName).length).toBe(120);
});
test("handles multiple commas and whitespace", () => {
expect(cleanLocation(" Graz Hbf , some address , 8020 Graz ")).toBe("Graz Hbf");
});
test("handles empty parts after split", () => {
expect(cleanLocation(", , Graz Hbf, ")).toBe("Graz Hbf");
});
});
describe("extractEvents", () => {
const makeEvent = (overrides = {}) => ({
type: "VEVENT",
uid: "test-uid",
summary: "Test Event",
start: new Date(Date.now() + 2 * 3600000), // 2 hours from now
location: "Graz Hbf",
...overrides,
});
test("filters non-VEVENT items", () => {
const data = {
"event-1": { type: "VEVENT", uid: "1", summary: "A", start: new Date(Date.now() + 3600000), location: "Graz Hbf" },
"val-1": { type: "VCALENDAR", uid: "2", summary: "B", start: new Date(Date.now() + 3600000), location: "Graz Hbf" },
};
const result = extractEvents(data);
expect(result.length).toBe(1);
expect(result[0].title).toBe("A");
});
test("filters events without location", () => {
const data = {
"event-1": makeEvent({ uid: "1", location: "Graz Hbf" }),
"event-2": makeEvent({ uid: "2", location: null }),
"event-3": makeEvent({ uid: "3", location: undefined }),
};
const result = extractEvents(data);
expect(result.length).toBe(1);
expect(result[0].id).toBe("1");
});
test("filters events without start date", () => {
const data = {
"event-1": makeEvent({ uid: "1" }),
"event-2": makeEvent({ uid: "2", start: null }),
"event-3": makeEvent({ uid: "3", start: undefined }),
};
const result = extractEvents(data);
expect(result.length).toBe(1);
});
test("filters past events", () => {
const data = {
"event-1": makeEvent({ uid: "1", start: new Date(Date.now() - 3600000) }),
"event-2": makeEvent({ uid: "2", start: new Date(Date.now() + 3600000) }),
};
const result = extractEvents(data);
expect(result.length).toBe(1);
expect(result[0].id).toBe("2");
});
test("filters events beyond horizon", () => {
const data = {
"event-1": makeEvent({ uid: "1", start: new Date(Date.now() + 3600000) }),
"event-2": makeEvent({ uid: "2", start: new Date(Date.now() + 30 * 24 * 3600000) }),
};
const result = extractEvents(data, 14);
expect(result.length).toBe(1);
expect(result[0].id).toBe("1");
});
test("respects custom horizon days", () => {
const data = {
"event-1": makeEvent({ uid: "1", start: new Date(Date.now() + 3600000) }),
"event-2": makeEvent({ uid: "2", start: new Date(Date.now() + 20 * 24 * 3600000) }),
};
// With 30 days horizon, both should be included
expect(extractEvents(data, 30).length).toBe(2);
// With 10 days horizon, only the first should be included
expect(extractEvents(data, 10).length).toBe(1);
});
test("sorts events by eventTime ascending", () => {
const data = {
"event-3": makeEvent({ uid: "3", start: new Date(Date.now() + 1 * 3600000) }),
"event-1": makeEvent({ uid: "1", start: new Date(Date.now() + 5 * 3600000) }),
"event-2": makeEvent({ uid: "2", start: new Date(Date.now() + 3 * 3600000) }),
};
const result = extractEvents(data);
expect(result[0].id).toBe("3");
expect(result[1].id).toBe("2");
expect(result[2].id).toBe("1");
});
test("includes required fields in output", () => {
const data = {
"event-1": makeEvent({ uid: "test-123", summary: "Meeting", location: "Graz Hbf, Europaplatz 5" }),
};
const result = extractEvents(data);
expect(result.length).toBe(1);
const event = result[0];
expect(event).toHaveProperty("id", "test-123");
expect(event).toHaveProperty("title", "Meeting");
expect(event).toHaveProperty("destination", "Graz Hbf");
expect(event).toHaveProperty("eventTime");
expect(event).toHaveProperty("source", "calendar");
});
test("uses default title when summary is missing", () => {
const data = {
"event-1": makeEvent({ uid: "1", summary: undefined }),
};
const result = extractEvents(data);
expect(result[0].title).toBe("Event");
});
test("uses generated uid when uid is missing", () => {
const data = {
"event-1": makeEvent({ uid: undefined }),
};
const result = extractEvents(data);
expect(result[0].id).toMatch(/^ical-/);
});
test("cleans location field in output", () => {
const data = {
"event-1": makeEvent({ uid: "1", location: "Graz Hbf, Europaplatz 5, 8020 Graz" }),
};
const result = extractEvents(data);
expect(result[0].destination).toBe("Graz Hbf");
});
test("returns empty array for empty input", () => {
expect(extractEvents({})).toEqual([]);
});
test("returns empty array for events array-like object", () => {
const data = [];
expect(extractEvents(data)).toEqual([]);
});
test("handles timezone-aware start dates", () => {
const future = new Date(Date.now() + 5 * 3600000);
const data = {
"event-1": makeEvent({ uid: "1", start: future.toISOString() }),
};
const result = extractEvents(data);
expect(result.length).toBe(1);
expect(result[0].eventTime).toBe(future.toISOString());
});
test("handles multiple events with same start time", () => {
const sameTime = new Date(Date.now() + 4 * 3600000);
const data = {
"event-1": makeEvent({ uid: "1", start: sameTime, location: "Graz Hbf" }),
"event-2": makeEvent({ uid: "2", start: sameTime, location: "Vienna Hbf" }),
};
const result = extractEvents(data);
expect(result.length).toBe(2);
});
});