Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npx prisma generate
env:
DATABASE_URL: "postgresql://mock:mock@localhost:5432/mock"

- name: Run tests with coverage
run: npx vitest run --coverage

Expand Down
90 changes: 90 additions & 0 deletions src/app/api/config/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({
NextResponse: {
json: (data: unknown, init?: { headers?: Record<string, string>; status?: number }) => ({
data,
status: init?.status ?? 200,
headers: init?.headers ?? {},
}),
},
}));

vi.mock("@/lib/config", () => ({
getConfig: vi.fn().mockReturnValue({
defaultCountry: "ES",
enabledCountries: ["ES", "DE"],
defaultFuel: "B7",
center: [-3.7, 40.4],
zoom: 6,
}),
COUNTRIES: {
ES: { code: "ES", name: "España", center: [-3.7, 40.4], zoom: 6 },
DE: { code: "DE", name: "Deutschland", center: [10.45, 51.16], zoom: 6 },
} as Record<string, { code: string; name: string; center: [number, number]; zoom: number }>,
}));

describe("config API", () => {
beforeEach(() => {
vi.resetModules();
});

it("returns config with enabled countries", async () => {
const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET()) as any;

expect(response.status).toBe(200);
expect(response.data.defaultCountry).toBe("ES");
expect(response.data.defaultFuel).toBe("B7");
expect(response.data.center).toEqual([-3.7, 40.4]);
expect(response.data.zoom).toBe(6);
expect(response.data.enabledCountries).toHaveLength(2);
expect(response.data.enabledCountries[0]).toEqual({
code: "ES",
name: "España",
center: [-3.7, 40.4],
zoom: 6,
});
expect(response.data.enabledCountries[1]).toEqual({
code: "DE",
name: "Deutschland",
center: [10.45, 51.16],
zoom: 6,
});
});

it("sets cache-control header", async () => {
const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET()) as any;

expect(response.headers["Cache-Control"]).toBe(
"public, s-maxage=3600, stale-while-revalidate=7200",
);
});

it("falls back to code when country not in COUNTRIES map", async () => {
const { getConfig } = await import("@/lib/config");
vi.mocked(getConfig).mockReturnValue({
defaultCountry: "ES",
enabledCountries: ["ES", "XX"],
defaultFuel: "B7",
center: [-3.7, 40.4],
zoom: 6,
clusterStations: true,
});

const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET()) as any;

const xx = response.data.enabledCountries.find(
(c: { code: string }) => c.code === "XX",
);
expect(xx).toBeDefined();
expect(xx.name).toBe("XX");
expect(xx.center).toBeUndefined();
expect(xx.zoom).toBeUndefined();
});
});
85 changes: 85 additions & 0 deletions src/app/api/geocode/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({
NextResponse: {
json: (data: unknown, init?: { headers?: Record<string, string>; status?: number }) => ({
data,
status: init?.status ?? 200,
headers: init?.headers ?? {},
}),
},
}));

vi.mock("@/lib/photon", () => ({
geocode: vi.fn(),
}));

function makeRequest(params: Record<string, string>) {
const url = new URL("http://localhost/api/geocode");
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
return { nextUrl: url };
}

describe("geocode API", () => {
beforeEach(() => {
vi.resetModules();
});

it("returns geocode results for valid query", async () => {
const { geocode } = await import("@/lib/photon");
vi.mocked(geocode).mockResolvedValue([
{ name: "Madrid", city: "Madrid", state: "Comunidad de Madrid", country: "Spain", coordinates: [-3.7, 40.4] },
]);

const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET(makeRequest({ q: "Madrid" }) as any)) as any;

expect(response.status).toBe(200);
expect(response.data).toHaveLength(1);
expect(response.data[0].name).toBe("Madrid");
expect(response.headers["Cache-Control"]).toBe("public, s-maxage=300, stale-while-revalidate=600");
});

it("passes lat/lon to geocode when provided", async () => {
const { geocode } = await import("@/lib/photon");
vi.mocked(geocode).mockResolvedValue([]);

const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await GET(makeRequest({ q: "Berlin", lat: "52.52", lon: "13.405" }) as any);

expect(geocode).toHaveBeenCalledWith("Berlin", 52.52, 13.405);
});

it("returns 400 when q is missing", async () => {
const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET(makeRequest({}) as any)) as any;

expect(response.status).toBe(400);
expect(response.data.error).toBe("Invalid parameters");
expect(response.data.details).toBeDefined();
});

it("returns 400 when lat is out of range", async () => {
const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET(makeRequest({ q: "test", lat: "999" }) as any)) as any;

expect(response.status).toBe(400);
expect(response.data.error).toBe("Invalid parameters");
});

it("returns 502 when geocode throws", async () => {
const { geocode } = await import("@/lib/photon");
vi.mocked(geocode).mockRejectedValue(new Error("Network error"));

const { GET } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await GET(makeRequest({ q: "Madrid" }) as any)) as any;

expect(response.status).toBe(502);
expect(response.data.error).toBe("Geocoding failed");
});
});
140 changes: 140 additions & 0 deletions src/app/api/route-detour/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({
NextResponse: {
json: (data: unknown, init?: { headers?: Record<string, string>; status?: number }) => ({
data,
status: init?.status ?? 200,
headers: init?.headers ?? {},
}),
},
}));

vi.mock("@/lib/valhalla", () => ({
getRouteDuration: vi.fn(),
}));

function makeRequest(body: unknown) {
return {
json: async () => body,
signal: new AbortController().signal,
};
}

function makeBadJsonRequest() {
return {
json: async () => { throw new SyntaxError("Unexpected token"); },
signal: new AbortController().signal,
};
}

describe("route-detour API", () => {
beforeEach(() => {
vi.resetModules();
});

it("returns NDJSON stream with correct headers", async () => {
const { getRouteDuration } = await import("@/lib/valhalla");
vi.mocked(getRouteDuration).mockResolvedValue(4000);

const { POST } = await import("./route");
const response = (await POST(makeRequest({
stations: [{ id: "s1", lon: -3.5, lat: 40.3 }],
origin: [-3.7, 40.4],
destination: [-0.37, 39.47],
routeDuration: 3600,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any)) as any;

expect(response).toBeInstanceOf(Response);
expect(response.headers.get("Content-Type")).toBe("application/x-ndjson");
expect(response.headers.get("Cache-Control")).toBe("no-cache, no-transform");
expect(response.headers.get("X-Accel-Buffering")).toBe("no");
});

it("streams station detour results as NDJSON lines", async () => {
const { getRouteDuration } = await import("@/lib/valhalla");
vi.mocked(getRouteDuration).mockResolvedValue(4200);

const { POST } = await import("./route");
const response = (await POST(makeRequest({
stations: [
{ id: "s1", lon: -3.5, lat: 40.3 },
{ id: "s2", lon: -2.0, lat: 40.0 },
],
origin: [-3.7, 40.4],
destination: [-0.37, 39.47],
routeDuration: 3600,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any)) as any;

const text = await response.text();
const lines = text.trim().split("\n");
expect(lines.length).toBe(2);

const result1 = JSON.parse(lines[0]);
expect(result1.id).toBe("s1");
expect(typeof result1.detourMin).toBe("number");
expect(result1.detourMin).toBeCloseTo(10, 0);
});

it("returns detourMin=-1 when getRouteDuration returns null", async () => {
const { getRouteDuration } = await import("@/lib/valhalla");
vi.mocked(getRouteDuration).mockResolvedValue(null);

const { POST } = await import("./route");
const response = (await POST(makeRequest({
stations: [{ id: "s1", lon: -3.5, lat: 40.3 }],
origin: [-3.7, 40.4],
destination: [-0.37, 39.47],
routeDuration: 3600,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any)) as any;

const text = await response.text();
const result = JSON.parse(text.trim());
expect(result.id).toBe("s1");
expect(result.detourMin).toBe(-1);
});

it("returns 400 for invalid JSON", async () => {
const { POST } = await import("./route");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (await POST(makeBadJsonRequest() as any)) as any;

expect(response.status).toBe(400);
expect(response.data.error).toBe("Invalid JSON");
});

it("returns 400 for invalid body schema", async () => {
const { POST } = await import("./route");
const response = (await POST(makeRequest({
stations: [],
origin: [-3.7, 40.4],
destination: [-0.37, 39.47],
routeDuration: 3600,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any)) as any;

expect(response.status).toBe(400);
expect(response.data.error).toBe("Invalid parameters");
});

it("returns detourMin=-1 when getRouteDuration throws", async () => {
const { getRouteDuration } = await import("@/lib/valhalla");
vi.mocked(getRouteDuration).mockRejectedValue(new Error("Valhalla down"));

const { POST } = await import("./route");
const response = (await POST(makeRequest({
stations: [{ id: "s1", lon: -3.5, lat: 40.3 }],
origin: [-3.7, 40.4],
destination: [-0.37, 39.47],
routeDuration: 3600,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any)) as any;

const text = await response.text();
const result = JSON.parse(text.trim());
expect(result.detourMin).toBe(-1);
});
});
Loading
Loading