Add correlation IDs and tests for monitoring

Generate short UUIDs in API route catch blocks to aid debugging.
Update existing API tests to assert the correlation ID. Add unit
tests for `useJourneys` and `useBikeRoute` hooks, and component
tests for `EventCard` and `CalendarView`.
This commit is contained in:
2026-05-10 08:59:17 +02:00
parent 7abfddd607
commit ce2a900545
12 changed files with 317 additions and 27 deletions
+9 -15
View File
@@ -34,26 +34,20 @@ Eliminate duplicated logic so each integration has one source of truth.
| Step | ✅ Implemented | ✔️ Reviewed | Notes | | Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------| |------|:------:|:------:|-------|
| **Step 8: Add Debounce to Lookup Hooks** (~25 min) | [x] | | 400ms setTimeout + AbortController in `useGeocode.ts` and `useDestinationStation.ts`. AbortError silently ignored. | | **Step 8: Add Debounce to Lookup Hooks** (~25 min) | [x] | [x] | 400ms setTimeout + AbortController in `useGeocode.ts` and `useDestinationStation.ts`. AbortError silently ignored. |
| **Step 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | | `useMemo` builds `Map<string, Event[]>` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. | | **Step 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | [x] | `useMemo` builds `Map<string, Event[]>` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. |
| **Step 10: Add Dark Mode Toggle** (~20 min) | [x] | | `useTheme.ts` created (localStorage + prefers-color-scheme). Sun/moon toggle button added to `Header.tsx`. | | **Step 10: Add Dark Mode Toggle** (~20 min) | [x] | [x] | `useTheme.ts` created (localStorage + prefers-color-scheme). Sun/moon toggle button added to `Header.tsx`. |
| **Step 11: Fix Bike Route Steps** (~5 min) | [x] | | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. | | **Step 11: Fix Bike Route Steps** (~5 min) | [x] | [x] | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. |
--- ---
## Phase 4 — Monitoring & Testing (~1 hour) ## Phase 4 — Monitoring & Testing (~1 hour)
- [ ] **Step 12: Add Correlation IDs to API Errors** (~15 min) | Step | ✅ Implemented | ✔️ Reviewed | Notes |
- Generate a short UUID (`randomUUID().slice(0, 8)`) in each API route's catch block; log it server-side and include it in the JSON error response |------|:------:|:------:|-------|
- Files: all `src/app/api/*/route.ts` | **Step 12: Add Correlation IDs to API Errors** (~15 min) | [x] | | `randomUUID().slice(0, 8)` in catch blocks of `bike-route`, `geocode`, `hafas`, `calendar`, `calendar/parse`. Logged server-side, returned in JSON. Existing API tests updated to assert `correlationId`. |
| **Step 13: Add Hook Tests** (~30 min) | [x] | | `useJourneys.test.ts` (4 tests: no-op when missing IDs, success, HTTP error, fetch throw). `useBikeRoute.test.ts` (4 tests: no-op when missing coords, success, HTTP error, fetch throw). |
- [ ] **Step 13: Add Hook Tests** (~30 min) | **Step 14: Add Component Tests** (~15 min) | [x] | | `EventCard.test.tsx` (renders title + destination, hooks mocked). `CalendarView.test.tsx` (3 tests: month header, event on correct day, overflow indicator). |
- Create `useJourneys.test.ts` and `useBikeRoute.test.ts` — mock `global.fetch`, test state transitions (loading → success, loading → error)
- Files: `src/hooks/__tests__/useJourneys.test.ts`, `src/hooks/__tests__/useBikeRoute.test.ts`
- [ ] **Step 14: Add Component Tests** (~15 min)
- Create `EventCard.test.tsx` and `CalendarView.test.tsx` — render with mock data, verify key elements are in the document
- Files: `src/app/event/__tests__/EventCard.test.tsx`, `src/app/calendar/__tests__/CalendarView.test.tsx`
--- ---
+2 -1
View File
@@ -57,7 +57,8 @@ describe("api/bike-route/route", () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
const data = await response.json(); const data = await response.json();
expect(data).toEqual({ error: "Internal server error" }); expect(data.error).toBe("Internal server error");
expect(data.correlationId).toHaveLength(8);
}); });
it("should handle no route found", async () => { it("should handle no route found", async () => {
+2 -1
View File
@@ -55,7 +55,8 @@ describe("api/geocode/route", () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
const data = await response.json(); const data = await response.json();
expect(data).toEqual({ error: "Internal server error" }); expect(data.error).toBe("Internal server error");
expect(data.correlationId).toHaveLength(8);
}); });
it("should handle no results found", async () => { it("should handle no results found", async () => {
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { BikeRoutingClient } from "@/lib/bike-routing-client"; import { BikeRoutingClient } from "@/lib/bike-routing-client";
@@ -27,7 +28,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(route); return NextResponse.json(route);
} catch (error) { } catch (error) {
console.error("Bike route API error:", error); const corrId = randomUUID().slice(0, 8);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); console.error(`[${corrId}] Bike route API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
} }
} }
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils"; import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
@@ -15,7 +16,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json(events); return NextResponse.json(events);
} catch (error) { } catch (error) {
console.error("Calendar parse API error:", error); const corrId = randomUUID().slice(0, 8);
return NextResponse.json({ error: "Failed to parse calendar" }, { status: 500 }); console.error(`[${corrId}] Calendar parse API error:`, error);
return NextResponse.json({ error: "Failed to parse calendar", correlationId: corrId }, { status: 500 });
} }
} }
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils"; import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
@@ -30,7 +31,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(events); return NextResponse.json(events);
} catch (error) { } catch (error) {
console.error("Calendar API error:", error); const corrId = randomUUID().slice(0, 8);
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: 500 }); console.error(`[${corrId}] Calendar API error:`, error);
return NextResponse.json({ error: "Failed to fetch calendar", correlationId: corrId }, { status: 500 });
} }
} }
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { GeocodingClient } from "@/lib/geocoding-client"; import { GeocodingClient } from "@/lib/geocoding-client";
@@ -22,7 +23,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(results[0]); return NextResponse.json(results[0]);
} catch (error) { } catch (error) {
console.error("Geocode API error:", error); const corrId = randomUUID().slice(0, 8);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); console.error(`[${corrId}] Geocode API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
} }
} }
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants"; import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
@@ -55,7 +56,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 }); return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
} }
console.error("HAFAS API error:", error); const corrId = randomUUID().slice(0, 8);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); console.error(`[${corrId}] HAFAS API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
} }
} }
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import CalendarView from "../CalendarView";
describe("CalendarView", () => {
it("renders month header", () => {
render(
<CalendarView
events={[]}
onDateSelect={() => {}}
selectedDate={new Date("2025-06-15")}
/>,
);
expect(screen.getByText("June 2025")).toBeInTheDocument();
});
it("renders events on the correct day", () => {
const event = {
id: "cal-1",
title: "Sprint Planning",
destination: "Vienna",
eventTime: new Date("2025-06-15T10:00:00"),
source: "calendar" as const,
};
render(
<CalendarView
events={[event]}
onDateSelect={() => {}}
selectedDate={new Date("2025-06-15")}
/>,
);
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
});
it("shows overflow indicator when more than 3 events on a day", () => {
const events = [
{ id: "1", title: "Event 1", destination: "A", eventTime: new Date("2025-06-10T09:00:00"), source: "manual" as const },
{ id: "2", title: "Event 2", destination: "B", eventTime: new Date("2025-06-10T10:00:00"), source: "manual" as const },
{ id: "3", title: "Event 3", destination: "C", eventTime: new Date("2025-06-10T11:00:00"), source: "manual" as const },
{ id: "4", title: "Event 4", destination: "D", eventTime: new Date("2025-06-10T12:00:00"), source: "manual" as const },
];
render(
<CalendarView
events={events}
onDateSelect={() => {}}
selectedDate={new Date("2025-06-10")}
/>,
);
expect(screen.getByText("+1 more")).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import EventCard from "../EventCard";
// Mock all hooks that EventCard depends on
vi.mock("@/hooks/useGeolocation", () => ({
useGeolocation: () => ({
location: null,
status: "pending",
requestLocation: () => {},
}),
}));
vi.mock("@/hooks/useDestinationStation", () => ({
useDestinationStation: () => ({
station: { name: "Wien Hbf", extId: "0WB0F0001500" },
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useGeocode", () => ({
useGeocode: () => ({
coords: null,
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useJourneys", () => ({
useJourneys: () => ({
journeys: [],
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useBikeRoute", () => ({
useBikeRoute: () => ({
bikeRoute: null,
loading: false,
error: null,
}),
}));
vi.mock("@/lib/countdown-utils", () => ({
calculateCountdown: () => ({
label: "No deadline set",
color: "text-gray-400",
urgent: false,
}),
}));
describe("EventCard", () => {
it("renders event title and destination", () => {
const mockEvent = {
id: "test-1",
title: "Team Meeting",
destination: "Wien Hbf",
eventTime: new Date("2025-12-01T14:00:00"),
source: "manual" as const,
};
const mockStation = { name: "Graz Hbf", extId: "0WB0F0000600" };
render(<EventCard event={mockEvent} originStation={mockStation} />);
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Wien Hbf")).toBeInTheDocument();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useBikeRoute } from "../useBikeRoute";
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useBikeRoute", () => {
it("does nothing when coordinates are missing", async () => {
const { result } = renderHook(() => useBikeRoute(undefined, undefined, 48.21, 16.38));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.bikeRoute).toBeNull();
expect(result.current.error).toBeNull();
});
it("returns bike route when API succeeds", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
distance: 1500,
duration: 300,
steps: [
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
],
}),
} as Response);
const { result } = renderHook(() =>
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
);
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.bikeRoute).not.toBeNull();
expect(result.current.bikeRoute?.distance).toBe(1500);
expect(result.current.bikeRoute?.duration).toBe(300);
expect(result.current.error).toBeNull();
});
it("sets error when API fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
} as Response);
const { result } = renderHook(() =>
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
);
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.bikeRoute).toBeNull();
});
it("sets error when fetch throws", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() =>
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
);
await waitFor(() => expect(result.current.error).toBe("Network error"));
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useJourneys } from "../useJourneys";
// HAFAS response shape that parseHafasJourneys can process
function makeHafasSuccessData() {
return {
svcResL: [
{
res: {
outConL: [
{
ctxRecon: "journey-1",
secL: [
{
dep: { dTimeS: "20250101120000", dTimeR: "20250101120000" },
prod: { name: "Railjet 123" },
},
{
arr: { aTimeS: "20250101130000", aTimeR: "20250101130000" },
prod: { name: "Railjet 123" },
},
],
},
],
},
},
],
};
}
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useJourneys", () => {
it("does nothing when station IDs are missing", async () => {
const { result } = renderHook(() => useJourneys(null, "0WB0F0001500", new Date("2025-01-01T12:00:00")));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.journeys).toHaveLength(0);
expect(result.current.error).toBeNull();
});
it("returns journeys when API succeeds", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => makeHafasSuccessData(),
} as Response);
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00")),
);
// Briefly enters loading
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.journeys).toHaveLength(1);
expect(result.current.journeys[0].id).toBe("journey-1");
expect(result.current.error).toBeNull();
});
it("sets error when API fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
} as Response);
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00")),
);
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.journeys).toHaveLength(0);
});
it("sets error when fetch throws", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00")),
);
await waitFor(() => expect(result.current.error).toBe("Network error"));
});
});