/* * Copyright (C) 2025 TomTom Navigation B.V. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fuzzySearch, geocodeAddress, poiSearch, reverseGeocode, searchAlongRoute, searchEVStations, searchInArea, toSearchArea, } from "./searchService"; import { recordFetch, type RecordedRequest } from "../shared/recordFetch"; vi.mock("../base/tomtomClient", () => ({ tomtomCallParams: () => ({ apiKey: "offline-test-key" }), })); // Offline: stub fetch and inspect the URL the SDK builds, so these tests check that // options reach the TomTom API rather than being dropped by the SDK's request builder. describe("Search SDK Service request parameters", () => { let requests: RecordedRequest[]; beforeEach(() => { requests = recordFetch({ summary: {}, results: [], addresses: [] }); }); afterEach(() => { vi.unstubAllGlobals(); }); async function lastRecorded(call: () => Promise): Promise { await call().catch(() => undefined); expect(requests.length).toBeGreaterThan(0); return requests[requests.length - 1]; } async function lastRequest(call: () => Promise): Promise { return (await lastRecorded(call)).url; } it("sends the geocode country filter", async () => { const url = await lastRequest(() => geocodeAddress("Main Street", { countries: ["NL", "BE"] })); expect(url.searchParams.get("countrySet")).toBe("NL,BE"); }); it("omits the geocode country filter when no countries are given", async () => { const url = await lastRequest(() => geocodeAddress("Main Street")); expect(url.searchParams.has("countrySet")).toBe(false); }); it("sends the reverse geocode radius, heading and view", async () => { const url = await lastRequest(() => reverseGeocode([4.89707, 52.377956], { radius: 250, heading: 90, view: "IN" }) ); expect(url.searchParams.get("radiusInMeters")).toBe("250"); expect(url.searchParams.get("vehicleHeadingInDegrees")).toBe("90"); expect(url.searchParams.get("geopoliticalView")).toBe("IN"); }); it.each([ ["geocode", () => geocodeAddress("Main Street", { radius: 500 })], ["fuzzy search", () => fuzzySearch("coffee", { radius: 500 })], ["POI search", () => poiSearch("coffee", { radius: 500 })], ])("rejects a %s radius without a position before calling the API", async (_name, call) => { await expect(call()).rejects.toMatchObject({ message: "radius needs position", data: { radius: 500 }, }); expect(requests).toHaveLength(0); }); it.each([ [ "geocode", () => geocodeAddress("Main Street", { position: [4.9, 52.37], boundingBox: [4.8, 52.3, 5.0, 52.4], }), ], [ "fuzzy search", () => fuzzySearch("coffee", { position: [4.9, 52.37], boundingBox: [4.8, 52.3, 5.0, 52.4] }), ], ])("rejects a %s position with a bounding box before calling the API", async (_name, call) => { await expect(call()).rejects.toThrow("Give position or boundingBox, not both"); expect(requests).toHaveLength(0); }); it("rejects a reverse geocode entityType with heading, which it ignores", async () => { await expect( reverseGeocode([4.89707, 52.377956], { entityType: "Municipality", heading: 90 }) ).rejects.toThrow("entityType ignores heading"); expect(requests).toHaveLength(0); }); it("rejects an area search without an area before calling the API", async () => { await expect(searchInArea({ query: "cafe" })).rejects.toThrow("Give a search area"); expect(requests).toHaveLength(0); }); // Reverse geocoding is on places API version 2, which takes the language as a header it("sends the reverse geocode language", async () => { const { headers } = await lastRecorded(() => reverseGeocode([4.89707, 52.377956], { language: "nl-NL" }) ); expect(headers.get("Accept-Language")).toBe("nl-NL"); }); it("sends known POI categories", async () => { const url = await lastRequest(() => poiSearch("dinner", { poiCategories: ["RESTAURANT"], position: [4.89707, 52.377956] }) ); expect(url.searchParams.get("categorySet")).toBe("7315"); }); it("rejects unknown POI categories with a short message before calling the API", async () => { await expect(poiSearch("dinner", { poiCategories: ["NOT_A_CATEGORY"] })).rejects.toMatchObject({ message: "Unknown POI categories. Use tomtom-poi-categories to find valid category codes.", data: { unknown_categories: ["NOT_A_CATEGORY"] }, }); expect(requests).toHaveLength(0); }); it("sends the EV connector filter", async () => { const url = await lastRequest(() => searchEVStations({ position: [4.89707, 52.377956], connectorTypes: ["IEC62196Type2CCS", "Chademo"], }) ); expect(url.searchParams.get("connectorSet")).toBe("IEC62196Type2CCS,Chademo"); }); it("sends area search as a geometry search", async () => { const url = await lastRequest(() => searchInArea({ query: "cafe", center: [4.89707, 52.377956], radius: 500, language: "nl-NL" }) ); expect(url.pathname).toContain("/geometrySearch/"); expect(url.searchParams.get("language")).toBe("nl-NL"); }); it("calculates the search-along-route corridor with the requested route type", async () => { await searchAlongRoute({ origin: [4.89707, 52.377956], destination: [5.10962, 52.09083], query: "fuel", routeType: "short", }).catch(() => undefined); expect(requests[0].url.pathname).toContain("/routing/"); expect(JSON.parse(requests[0].body)).toMatchObject({ routeType: "short" }); }); }); describe("toSearchArea", () => { const polygon = [ [4.88, 52.37], [4.9, 52.37], [4.9, 52.38], ]; it.each([ [ "a circle and a polygon", { center: [4.89, 52.37], radius: 500, polygon }, "Give one search area", ], [ "a center without a radius", { center: [4.89, 52.37], polygon }, "center and radius go together", ], ["a radius without a center", { radius: 500 }, "center and radius go together"], ])("rejects %s", (_name, params, message) => { expect(() => toSearchArea(params)).toThrow(message); }); it("closes an open polygon", () => { const area = toSearchArea({ polygon }); expect(area?.kind === "polygon" && area.polygon.coordinates[0]).toEqual([ ...polygon, polygon[0], ]); }); it("turns the top-left and bottom-right corners into a rectangle", () => { const area = toSearchArea({ boundingBox: [ [4.8, 52.45], [4.95, 52.3], ], }); expect(area?.kind === "boundingBox" && area.polygon.coordinates[0]).toEqual([ [4.8, 52.3], [4.95, 52.3], [4.95, 52.45], [4.8, 52.45], [4.8, 52.3], ]); }); });