From ce2a900545f1bf55c451110bd42efcc36ebb6ba5 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sun, 10 May 2026 08:59:17 +0200 Subject: [PATCH] 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`. --- CHECKLIST.md | 24 ++--- src/app/api/__tests__/bike-route.test.ts | 3 +- src/app/api/__tests__/geocode.test.ts | 3 +- src/app/api/bike-route/route.ts | 6 +- src/app/api/calendar/parse/route.ts | 6 +- src/app/api/calendar/route.ts | 6 +- src/app/api/geocode/route.ts | 6 +- src/app/api/hafas/route.ts | 6 +- .../calendar/__tests__/CalendarView.test.tsx | 53 +++++++++++ src/app/event/__tests__/EventCard.test.tsx | 70 ++++++++++++++ src/hooks/__tests__/useBikeRoute.test.ts | 70 ++++++++++++++ src/hooks/__tests__/useJourneys.test.ts | 91 +++++++++++++++++++ 12 files changed, 317 insertions(+), 27 deletions(-) create mode 100644 src/app/calendar/__tests__/CalendarView.test.tsx create mode 100644 src/app/event/__tests__/EventCard.test.tsx create mode 100644 src/hooks/__tests__/useBikeRoute.test.ts create mode 100644 src/hooks/__tests__/useJourneys.test.ts diff --git a/CHECKLIST.md b/CHECKLIST.md index d670810..2e85882 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -34,26 +34,20 @@ Eliminate duplicated logic so each integration has one source of truth. | 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 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | | `useMemo` builds `Map` 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 11: Fix Bike Route Steps** (~5 min) | [x] | | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. | +| **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] | [x] | `useMemo` builds `Map` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. | +| **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] | [x] | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. | --- ## Phase 4 — Monitoring & Testing (~1 hour) -- [ ] **Step 12: Add Correlation IDs to API Errors** (~15 min) - - 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 13: Add Hook Tests** (~30 min) - - 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` +| Step | ✅ Implemented | ✔️ Reviewed | Notes | +|------|:------:|:------:|-------| +| **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 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). | --- diff --git a/src/app/api/__tests__/bike-route.test.ts b/src/app/api/__tests__/bike-route.test.ts index a89df83..6eb5c35 100644 --- a/src/app/api/__tests__/bike-route.test.ts +++ b/src/app/api/__tests__/bike-route.test.ts @@ -57,7 +57,8 @@ describe("api/bike-route/route", () => { expect(response.status).toBe(500); 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 () => { diff --git a/src/app/api/__tests__/geocode.test.ts b/src/app/api/__tests__/geocode.test.ts index cc8540e..c210b17 100644 --- a/src/app/api/__tests__/geocode.test.ts +++ b/src/app/api/__tests__/geocode.test.ts @@ -55,7 +55,8 @@ describe("api/geocode/route", () => { expect(response.status).toBe(500); 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 () => { diff --git a/src/app/api/bike-route/route.ts b/src/app/api/bike-route/route.ts index bb87c73..6d61816 100644 --- a/src/app/api/bike-route/route.ts +++ b/src/app/api/bike-route/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { BikeRoutingClient } from "@/lib/bike-routing-client"; @@ -27,7 +28,8 @@ export async function GET(request: NextRequest) { return NextResponse.json(route); } catch (error) { - console.error("Bike route API error:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + const corrId = randomUUID().slice(0, 8); + console.error(`[${corrId}] Bike route API error:`, error); + return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 }); } } diff --git a/src/app/api/calendar/parse/route.ts b/src/app/api/calendar/parse/route.ts index e141535..19a0afa 100644 --- a/src/app/api/calendar/parse/route.ts +++ b/src/app/api/calendar/parse/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { extractEvents } from "@/lib/calendar-utils"; import { DEFAULT_DAYS } from "@/lib/constants"; @@ -15,7 +16,8 @@ export async function POST(request: NextRequest) { return NextResponse.json(events); } catch (error) { - console.error("Calendar parse API error:", error); - return NextResponse.json({ error: "Failed to parse calendar" }, { status: 500 }); + const corrId = randomUUID().slice(0, 8); + console.error(`[${corrId}] Calendar parse API error:`, error); + return NextResponse.json({ error: "Failed to parse calendar", correlationId: corrId }, { status: 500 }); } } diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index 7e010b4..1ab7e2f 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { extractEvents } from "@/lib/calendar-utils"; import { DEFAULT_DAYS } from "@/lib/constants"; @@ -30,7 +31,8 @@ export async function GET(request: NextRequest) { return NextResponse.json(events); } catch (error) { - console.error("Calendar API error:", error); - return NextResponse.json({ error: "Failed to fetch calendar" }, { status: 500 }); + const corrId = randomUUID().slice(0, 8); + console.error(`[${corrId}] Calendar API error:`, error); + return NextResponse.json({ error: "Failed to fetch calendar", correlationId: corrId }, { status: 500 }); } } diff --git a/src/app/api/geocode/route.ts b/src/app/api/geocode/route.ts index 03ab7ae..6e8849d 100644 --- a/src/app/api/geocode/route.ts +++ b/src/app/api/geocode/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { GeocodingClient } from "@/lib/geocoding-client"; @@ -22,7 +23,8 @@ export async function GET(request: NextRequest) { return NextResponse.json(results[0]); } catch (error) { - console.error("Geocode API error:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + const corrId = randomUUID().slice(0, 8); + console.error(`[${corrId}] Geocode API error:`, error); + return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 }); } } diff --git a/src/app/api/hafas/route.ts b/src/app/api/hafas/route.ts index a40ace1..ec89c7f 100644 --- a/src/app/api/hafas/route.ts +++ b/src/app/api/hafas/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { NextRequest, NextResponse } from "next/server"; 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 }); } - console.error("HAFAS API error:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + const corrId = randomUUID().slice(0, 8); + console.error(`[${corrId}] HAFAS API error:`, error); + return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 }); } } diff --git a/src/app/calendar/__tests__/CalendarView.test.tsx b/src/app/calendar/__tests__/CalendarView.test.tsx new file mode 100644 index 0000000..bdee0fb --- /dev/null +++ b/src/app/calendar/__tests__/CalendarView.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + selectedDate={new Date("2025-06-10")} + />, + ); + expect(screen.getByText("+1 more")).toBeInTheDocument(); + }); +}); diff --git a/src/app/event/__tests__/EventCard.test.tsx b/src/app/event/__tests__/EventCard.test.tsx new file mode 100644 index 0000000..9012b1d --- /dev/null +++ b/src/app/event/__tests__/EventCard.test.tsx @@ -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(); + expect(screen.getByText("Team Meeting")).toBeInTheDocument(); + expect(screen.getByText("Wien Hbf")).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/useBikeRoute.test.ts b/src/hooks/__tests__/useBikeRoute.test.ts new file mode 100644 index 0000000..8e9597c --- /dev/null +++ b/src/hooks/__tests__/useBikeRoute.test.ts @@ -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")); + }); +}); diff --git a/src/hooks/__tests__/useJourneys.test.ts b/src/hooks/__tests__/useJourneys.test.ts new file mode 100644 index 0000000..7976c3d --- /dev/null +++ b/src/hooks/__tests__/useJourneys.test.ts @@ -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")); + }); +});