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
104 changes: 104 additions & 0 deletions src/app/api/stations/nearest/nearest.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";

// Drives the real GET handler against PostGIS. At 52° N a degree of longitude
// is only ~68 km, so a query that measures in planar degrees overstates every
// east-west distance by ~1.64x: it ranks a station 0.8 km east behind one
// 1.0 km north, and drops a station 9 km east from a 10 km radius.
const hasDocker = process.env.SKIP_INTEGRATION !== "1";
const d = hasDocker ? describe : describe.skip;

const ORIGIN = { lat: 52.37, lon: 4.89 };
const KM_PER_DEG_LON = 111.32 * Math.cos((ORIGIN.lat * Math.PI) / 180);

const STATIONS = [
{ name: "East 0.8 km", lon: ORIGIN.lon + 0.8 / KM_PER_DEG_LON, lat: ORIGIN.lat, km: 0.8 },
{ name: "North 1.0 km", lon: ORIGIN.lon, lat: ORIGIN.lat + 0.009, km: 1.0 },
{ name: "East 9 km", lon: ORIGIN.lon + 9 / KM_PER_DEG_LON, lat: ORIGIN.lat, km: 9 },
{ name: "North 11 km", lon: ORIGIN.lon, lat: ORIGIN.lat + 0.099, km: 11 },
];

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

d("stations/nearest GET handler against PostGIS (integration)", () => {
let container: StartedPostgreSqlContainer;
let client: Client;
let GET: (req: unknown) => Promise<Response>;

beforeAll(async () => {
container = await new PostgreSqlContainer("postgis/postgis:17-3.4").start();
client = new Client({ connectionString: container.getConnectionUri() });
await client.connect();
await client.query("CREATE EXTENSION IF NOT EXISTS postgis");
await client.query(`
CREATE TABLE stations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR NOT NULL,
country VARCHAR NOT NULL,
name VARCHAR NOT NULL,
brand VARCHAR,
address TEXT NOT NULL,
city VARCHAR NOT NULL,
geom geometry(Point, 4326) NOT NULL,
station_type VARCHAR NOT NULL DEFAULT 'fuel',
max_power_kw DECIMAL
)
`);
await client.query("CREATE INDEX stations_geom_geography_idx ON stations USING GIST ((geom::geography))");
await client.query(`
CREATE TABLE fuel_prices (
id BIGSERIAL PRIMARY KEY,
station_id UUID NOT NULL REFERENCES stations(id),
fuel_type VARCHAR NOT NULL,
price DECIMAL(6,3) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
reported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ev_fees JSONB
)
`);

for (const [i, s] of STATIONS.entries()) {
const row = await client.query<{ id: string }>(
`INSERT INTO stations (external_id, country, name, address, city, geom)
VALUES ($1, 'NL', $2, 'Test 1', 'Amsterdam', ST_SetSRID(ST_MakePoint($3, $4), 4326))
RETURNING id`,
[`t-${i}`, s.name, s.lon, s.lat],
);
await client.query(
`INSERT INTO fuel_prices (station_id, fuel_type, price) VALUES ($1, 'E10', 2.0)`,
[row.rows[0].id],
);
}

process.env.DATABASE_URL = container.getConnectionUri();
vi.resetModules();
const mod = await import("./route");
GET = mod.GET as unknown as (req: unknown) => Promise<Response>;
});

afterAll(async () => {
await client?.end();
await container?.stop();
});

it("ranks and filters by true ground distance", async () => {
const res = await GET(
makeRequest({ lat: String(ORIGIN.lat), lon: String(ORIGIN.lon), radius_km: "10", fuel: "E10", limit: "10" }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
features: Array<{ properties: { name: string; distanceKm: number } }>;
};

expect(body.features.map((f) => f.properties.name)).toEqual(["East 0.8 km", "North 1.0 km", "East 9 km"]);
for (const f of body.features) {
const want = STATIONS.find((s) => s.name === f.properties.name)!.km;
expect(Math.abs(f.properties.distanceKm - want) / want).toBeLessThan(0.02);
}
});
});
15 changes: 14 additions & 1 deletion src/app/api/stations/nearest/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,23 @@ describe("stations/nearest API", () => {
await GET(makeRequest(validParams) as any);

const callArgs = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0];
// Non-EV query: (sql, lon, lat, radiusDeg, limit, fuel)
// Non-EV query: (sql, lon, lat, radiusM, limit, fuel)
expect(callArgs[4]).toBe(5);
});

it.each(["B7", "EV"])("measures %s distance and radius on the sphere, in metres", async (fuel) => {
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([]);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
await GET(makeRequest({ ...validParams, fuel }) as any);

const [sql, , , radius] = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0] as [string, ...unknown[]];
expect(radius).toBe(10000);
expect(sql).toContain("ST_Distance(s.geom::geography");
expect(sql).toMatch(/ST_DWithin\(\s*s\.geom::geography,/);
expect(sql).not.toContain("111.32");
});

it("accepts custom limit parameter", async () => {
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([]);

Expand Down
23 changes: 13 additions & 10 deletions src/app/api/stations/nearest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ export async function GET(request: NextRequest) {

try {
const isEV = fuel === "EV";
// Approximate degree conversion for ST_DWithin on geometry (not geography)
const radiusDeg = radius_km / 111.32;
// Distance and radius are measured on the sphere (geography, metres), so a
// kilometre east-west counts the same as a kilometre north-south. Planar
// degrees * 111.32 overstated east-west distance by 1/cos(lat). The
// filter uses the functional GiST stations_geom_geography_idx.
const radiusM = radius_km * 1000;

const rows: NearestStationRow[] = isEV
? await prisma.$queryRawUnsafe(
Expand All @@ -97,7 +100,7 @@ export async function GET(request: NextRequest) {
fp.reported_at,
fp.ev_fees,
s.max_power_kw::int AS max_power_kw,
(ST_Distance(s.geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) * 111.32)::float AS distance_km
(ST_Distance(s.geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) / 1000)::float AS distance_km
FROM stations s
LEFT JOIN LATERAL (
SELECT price, currency, reported_at, ev_fees
Expand All @@ -109,16 +112,16 @@ export async function GET(request: NextRequest) {
) fp ON true
WHERE s.station_type IN ('ev_charger', 'both')
AND ST_DWithin(
s.geom,
ST_SetSRID(ST_MakePoint($1, $2), 4326),
s.geom::geography,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$3
)
ORDER BY distance_km ASC
LIMIT $4
`,
lon,
lat,
radiusDeg,
radiusM,
limit,
)
: await prisma.$queryRawUnsafe(
Expand All @@ -136,7 +139,7 @@ export async function GET(request: NextRequest) {
fp.price::float AS price,
COALESCE(fp.currency, 'EUR') AS currency,
fp.reported_at,
(ST_Distance(s.geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) * 111.32)::float AS distance_km
(ST_Distance(s.geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) / 1000)::float AS distance_km
FROM stations s
JOIN LATERAL (
SELECT price, currency, reported_at
Expand All @@ -147,16 +150,16 @@ export async function GET(request: NextRequest) {
LIMIT 1
) fp ON true
WHERE ST_DWithin(
s.geom,
ST_SetSRID(ST_MakePoint($1, $2), 4326),
s.geom::geography,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$3
)
ORDER BY distance_km ASC
LIMIT $4
`,
lon,
lat,
radiusDeg,
radiusM,
limit,
fuel,
);
Expand Down
Loading