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:
+9
-15
@@ -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<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 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<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] | [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). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user