Refactor API routes and hooks to use library clients

- Wire `api/geocode` and `api/bike-route` to use `GeocodingClient`
  and `BikeRoutingClient` instead of raw fetch calls
- Move `parseHafasJourneys` from `useJourneys` to `hafas-client`
- Remove dead code `src/lib/live-status-utils.ts`
- Update test setup to import `@testing-library/jest-dom/vitest`
This commit is contained in:
2026-05-10 03:23:07 +02:00
parent 77b8c6db98
commit 28c25c32ab
9 changed files with 108 additions and 282 deletions
+4 -4
View File
@@ -21,19 +21,19 @@ Fix bugs that crash the app or lose user data.
Eliminate duplicated logic so each integration has one source of truth.
- [ ] **Step 4: Consolidate HAFAS Journey Parsing** (~40 min)
- [x] **Step 4: Consolidate HAFAS Journey Parsing** (~40 min)
- Move `parseHafasJourneys` from `useJourneys.ts` into `hafas-client.ts` and export it; import from `useJourneys.ts` (Option A — minimal risk)
- Files: `src/lib/hafas-client.ts`, `src/hooks/useJourneys.ts`
- [ ] **Step 5: Wire API Routes to Use Library Clients** (~30 min)
- [x] **Step 5: Wire API Routes to Use Library Clients** (~30 min)
- Replace raw `fetch()` in `api/geocode/route.ts` with `GeocodingClient`, and in `api/bike-route/route.ts` with `BikeRoutingClient` (module-level singleton, proper error handling)
- Files: `src/app/api/geocode/route.ts`, `src/app/api/bike-route/route.ts`
- [ ] **Step 6: Remove Dead Code** (~5 min)
- [x] **Step 6: Remove Dead Code** (~5 min)
- Delete `src/lib/live-status-utils.ts` entirely; remove its export from `src/lib/index.ts`
- File: `src/lib/live-status-utils.ts`, `src/lib/index.ts`
- [ ] **Step 7: Create Missing Test Setup File** (~10 min)
- [x] **Step 7: Create Missing Test Setup File** (~10 min)
- Create `src/test/setup.ts` with `import "@testing-library/jest-dom/vitest"` so jsdom matchers are registered globally
- File: `src/test/setup.ts`
+21 -50
View File
@@ -1,14 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "../bike-route/route";
// Mock the fetch function to avoid making actual HTTP requests
global.fetch = vi.fn();
const mockGetBikeRoute = vi.fn();
vi.mock("@/lib/bike-routing-client", () => ({
BikeRoutingClient: class MockBikeRoutingClient {
getBikeRoute = mockGetBikeRoute;
},
}));
const { GET } = await import("../bike-route/route");
describe("api/bike-route/route", () => {
beforeEach(() => {
vi.resetAllMocks();
global.fetch = vi.fn();
mockGetBikeRoute.mockReset();
});
it("should return error when no required parameters are provided", async () => {
@@ -21,40 +26,14 @@ describe("api/bike-route/route", () => {
});
it("should handle valid bike route request", async () => {
// Mock successful fetch response
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
distance: 1500,
duration: 300,
routes: [
{
distance: 1500,
duration: 300,
legs: [
{
steps: [
{
name: "Start",
distance: 100,
duration: 10,
maneuver: { instruction: "Go straight", type: "straight" },
},
{
name: "Turn left",
distance: 200,
duration: 20,
maneuver: { instruction: "Turn left at the corner", type: "turn", modifier: "left" },
},
],
},
],
},
],
}),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
mockGetBikeRoute.mockResolvedValue({
distance: 1500,
duration: 300,
steps: [
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
{ name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" },
],
});
const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
@@ -68,8 +47,8 @@ describe("api/bike-route/route", () => {
expect(data).toHaveProperty("steps");
});
it("should handle fetch error", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
it("should handle client error", async () => {
mockGetBikeRoute.mockRejectedValue(new Error("Network error"));
const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
@@ -82,15 +61,7 @@ describe("api/bike-route/route", () => {
});
it("should handle no route found", async () => {
// Mock fetch response with empty routes
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
routes: [],
}),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
mockGetBikeRoute.mockResolvedValue(null);
const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
+11 -42
View File
@@ -1,62 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { OSRM_URL } from "@/lib/constants";
import { BikeRoute } from "@/types";
import { BikeRoutingClient } from "@/lib/bike-routing-client";
interface OsrmStep {
name: string;
distance: number;
duration: number;
maneuver: { instruction?: string; type: string; modifier?: string };
}
// Module-level singleton — cache persists across requests
const client = new BikeRoutingClient();
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const fromLat = searchParams.get("fromLat");
const fromLng = searchParams.get("fromLng");
const toLat = searchParams.get("toLat");
const toLng = searchParams.get("toLng");
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (!fromLat || !fromLng || !toLat || !toLng) {
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 },
);
}
// Build OSRM URL
const url = new URL(`${OSRM_URL}/route/v1/bicycle/${fromLng},${fromLat};${toLng},${toLat}`);
url.searchParams.append("overview", "false");
const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
const response = await fetch(url.toString(), {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
return NextResponse.json({ error: "Bike routing request failed" }, { status: response.status });
}
const data = await response.json();
if (!data.routes || data.routes.length === 0) {
if (!route) {
return NextResponse.json({ error: "No route found" }, { status: 404 });
}
const route = data.routes[0];
const bikeRoute: BikeRoute = {
distance: route.distance,
duration: route.duration,
steps:
route.legs?.[0]?.steps?.map((step: OsrmStep) => ({
name: step.name || "",
distance: step.distance,
duration: step.duration,
instruction: step.maneuver.instruction,
})) || [],
};
return NextResponse.json(bikeRoute);
return NextResponse.json(route);
} catch (error) {
console.error("Bike route API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+6 -57
View File
@@ -1,10 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "@/lib/constants";
import { GeocodeResult } from "@/types";
import { GeocodingClient } from "@/lib/geocoding-client";
// Simple in-memory cache with TTL
const cache = new Map<string, { result: GeocodeResult; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Module-level singleton — cache persists across requests
const client = new GeocodingClient();
export async function GET(request: NextRequest) {
try {
@@ -16,62 +14,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
}
// Create cache key
const cacheKey = `${name}|${countrycodes || ""}`;
const results = await client.geocode(name, countrycodes || undefined);
// Check cache
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(cached.result);
}
// Build URL with parameters
const url = new URL(`${NOMINATIM_URL}/search`);
url.searchParams.append("q", name);
url.searchParams.append("format", "json");
url.searchParams.append("limit", "1");
if (countrycodes) {
url.searchParams.append("countrycodes", countrycodes);
}
let response: Response;
try {
response = await fetch(url.toString(), {
headers: {
"User-Agent": NOMINATIM_USER_AGENT,
},
});
} catch (error) {
console.error("Geocoding fetch failed:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
if (!response.ok) {
// If response is not ok, return error status without caching.
return NextResponse.json({ error: "Geocoding request failed" }, { status: response.status });
}
const data = await response.json();
if (!data || data.length === 0) {
// If no results, return error status without caching.
if (results.length === 0) {
return NextResponse.json({ error: "No results found" }, { status: 404 });
}
const result: GeocodeResult = {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
display_name: data[0].display_name,
};
// Cache the result ONLY on success path
cache.set(cacheKey, {
result,
timestamp: Date.now(),
});
return NextResponse.json(result);
return NextResponse.json(results[0]);
} catch (error) {
console.error("Geocode API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+8 -61
View File
@@ -1,67 +1,14 @@
import { useState, useEffect } from "react";
import type { Journey } from "@/types";
import { parseHafasTime, hafasDateTime } from "@/lib/hafas-time";
import { parseHafasJourneys } from "@/lib/hafas-client";
import { hafasDateTime } from "@/lib/hafas-time";
interface HafasJourney {
ctxRecon?: string;
secL?: Array<{
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
arr?: { aTimeS?: string; aTimeR?: string };
jny?: {
prodX?: number;
stopL?: Array<{ name: string }>;
dlySum?: number;
isCncl?: boolean;
};
chgDurR?: number;
}>;
}
function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
// HAFAS response shape is too complex for a clean TypeScript type; use any for parsing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
const data = json as any;
const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? [];
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
const last = con.secL?.[con.secL.length - 1];
const dep = first?.dep;
const arr = last?.arr;
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate;
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
const delayMs = rD.getTime() - sD.getTime();
const delay = Math.max(0, Math.round(delayMs / 60000));
const trains = (con.secL ?? [])
.filter((s) => s.jny)
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
.filter(Boolean);
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
const platform = first?.dep?.dPlatfS ?? "";
return {
id: con.ctxRecon ?? `journey-${i}`,
sD,
rD,
sA,
rA,
delay,
platform,
changes,
trains,
cancelled,
};
});
}
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date, refreshKey = 0) {
export function useJourneys(
fromStationExtId: string | null,
toStationExtId: string | null,
date: Date,
refreshKey = 0,
) {
const [journeys, setJourneys] = useState<Journey[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
+57 -43
View File
@@ -3,9 +3,9 @@ import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
import { parseHafasTime, hafasDateTime } from "./hafas-time";
import { ApiClient } from "./api-service";
// ---------------------------------------------------------------------------
// -------------------------------------------------------------
// HAFAS response types (internal, not exported)
// ---------------------------------------------------------------------------
// -------------------------------------------------------------
interface HafasLocation {
type: "S" | "A" | "P";
@@ -23,7 +23,7 @@ interface HafasLocationResponse {
}>;
}
interface HafasJourney {
export interface HafasJourney {
ctxRecon?: string;
secL?: Array<{
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
@@ -46,9 +46,60 @@ interface HafasTripResponse {
}>;
}
// ---------------------------------------------------------------------------
// -------------------------------------------------------------
// Shared HAFAS journey parser
// -------------------------------------------------------------
/**
* Parse a raw HAFAS response into Journey[].
* Works with both typed and untyped (raw JSON) responses.
*/
export function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
const data = json as any;
const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? [];
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
const last = con.secL?.[con.secL.length - 1];
const dep = first?.dep;
const arr = last?.arr;
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate;
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
const delayMs = rD.getTime() - sD.getTime();
const delay = Math.max(0, Math.round(delayMs / 60000));
const trains = (con.secL ?? [])
.filter((s) => s.jny)
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
.filter(Boolean);
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
const platform = first?.dep?.dPlatfS ?? "";
return {
id: con.ctxRecon ?? `journey-${i}`,
sD,
rD,
sA,
rA,
delay,
platform,
changes,
trains,
cancelled,
};
});
}
// -------------------------------------------------------------
// HafasClient — Wraps the ÖBB HAFAS journey-planning API
// ---------------------------------------------------------------------------
// -------------------------------------------------------------
export class HafasClient {
private client: ApiClient;
@@ -103,44 +154,7 @@ export class HafasClient {
const res = await this.client.post<HafasTripResponse>("", body);
const outConL: HafasJourney[] = res?.svcResL?.[0]?.res?.outConL ?? [];
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
const last = con.secL?.[con.secL.length - 1];
const dep = first?.dep;
const arr = last?.arr;
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : date;
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
const delayMs = rD.getTime() - sD.getTime();
const delay = Math.max(0, Math.round(delayMs / 60000));
const trains = (con.secL ?? [])
.filter((s) => s.jny)
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
.filter(Boolean);
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
const platform = first?.dep?.dPlatfS ?? "";
return {
id: con.ctxRecon ?? `journey-${i}`,
sD,
rD,
sA,
rA,
delay,
platform,
changes,
trains,
cancelled,
};
});
return parseHafasJourneys(res, hafasDate, date);
}
/**
-1
View File
@@ -5,7 +5,6 @@ export * from "./geocoding-client";
export * from "./bike-routing-client";
export * from "./calendar-utils";
export * from "./status-utils";
export * from "./live-status-utils";
export * from "./countdown-utils";
export * from "./formatting";
export * from "./constants";
-23
View File
@@ -1,23 +0,0 @@
// Live status utilities for TimeToLeave
import type { LiveStatus } from "@/types";
export class LiveStatusUtils {
static async getLiveStatus(_journeyId: string): Promise<LiveStatus> {
// This would make an actual API call to get live status
// For now, we'll return mock data
return null;
}
static getStatusMessage(status: LiveStatus): string {
switch (status) {
case true:
return "On time";
case false:
return "Delayed or cancelled";
case null:
return "No live data";
default:
return "Unknown status";
}
}
}
+1 -1
View File
@@ -1 +1 @@
import "@testing-library/jest-dom";
import "@testing-library/jest-dom/vitest";