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")); + }); +});