/* * 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. */ // searchHandler.test.ts import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GeocodeSearchParams, ReverseGeocodeSearchParams, } from "../schemas/search/searchSchema"; import { expectDropped, expectKept, loadFixture } from "./shared/__fixtures__"; // Create typed mocks const createMocks = () => { const geocodeAddress = vi.fn(); const reverseGeocode = vi.fn(); const fuzzySearch = vi.fn(); const poiSearch = vi.fn(); const searchNearby = vi.fn(); const fetchPOICategories = vi.fn(); const searchInArea = vi.fn(); const searchEVStations = vi.fn(); const searchAlongRoute = vi.fn(); const loggerInfo = vi.fn(); const loggerError = vi.fn(); return { searchService: { geocodeAddress, reverseGeocode, fuzzySearch, poiSearch, searchNearby, fetchPOICategories, searchInArea, searchEVStations, searchAlongRoute, }, logger: { info: loggerInfo, error: loggerError, warn: vi.fn(), debug: vi.fn(), }, }; }; const mocks = createMocks(); // Use correct relative path for ESM Vitest vi.mock("../services/search/searchService", () => ({ geocodeAddress: mocks.searchService.geocodeAddress, reverseGeocode: mocks.searchService.reverseGeocode, fuzzySearch: mocks.searchService.fuzzySearch, poiSearch: mocks.searchService.poiSearch, searchNearby: mocks.searchService.searchNearby, fetchPOICategories: mocks.searchService.fetchPOICategories, searchInArea: mocks.searchService.searchInArea, searchEVStations: mocks.searchService.searchEVStations, searchAlongRoute: mocks.searchService.searchAlongRoute, })); vi.mock("../utils/logger", () => ({ logger: mocks.logger, })); // Import after mocking const { createGeocodeHandler, createReverseGeocodeHandler, createFuzzySearchHandler, createPoiSearchHandler, createNearbySearchHandler, createPOICategoriesHandler, createEVSearchHandler, createAreaSearchHandler, createSearchAlongRouteHandler, } = await import("./searchHandler"); describe("createGeocodeHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.clearAllMocks(); }); it("should return geocoded result for valid query", async () => { const fakeResult = loadFixture("orbis-geocode"); mocks.searchService.geocodeAddress.mockResolvedValue(fakeResult); const handler = createGeocodeHandler(); const params = { query: "Dam 1, Amsterdam", response_detail: "full" as const }; const response = await handler(params); expect(mocks.searchService.geocodeAddress).toHaveBeenCalledWith("Dam 1, Amsterdam", {}); expect(response).toEqual({ content: [{ type: "text", text: JSON.stringify({ ...fakeResult, show_ui: false }) }], _meta: { show_ui: false }, }); expect(response.content[0].text).not.toContain('"_meta"'); expect(mocks.logger.error).not.toHaveBeenCalled(); }); it("should return a trimmed, minified result by default", async () => { const fakeResult = loadFixture("orbis-geocode"); mocks.searchService.geocodeAddress.mockResolvedValue(fakeResult); const handler = createGeocodeHandler(); const response = await handler({ query: "Dam 1, Amsterdam", show_ui: false }); const text = response.content[0].text; expect(text).not.toContain("\n"); const parsed = JSON.parse(text); expect(parsed.features[0].properties.address.freeformAddress).toBe( fakeResult.features[0].properties.address.freeformAddress ); expect(parsed.features[0].properties.matchConfidence).toBeUndefined(); expect(parsed).not.toHaveProperty("_meta"); expect(parsed.show_ui).toBe(false); expect(response._meta).toEqual({ show_ui: false }); }); it("should handle errors from geocodeAddress", async () => { mocks.searchService.geocodeAddress.mockRejectedValue(new Error("fail")); const handler = createGeocodeHandler(); const params = { query: "Bad Address" }; const response = await handler(params); expect(response.isError).toBe(true); expect(response.content[0].text).toContain("fail"); expect(mocks.logger.error).toHaveBeenCalled(); }); it("should return error if query param is missing", async () => { const handler = createGeocodeHandler(); // testing missing param — bypass type checking for error-path test const response = await handler({} as GeocodeSearchParams); expect(response.isError).toBe(true); expect(response.content[0].text).toMatch(/error/i); expect(mocks.logger.error).toHaveBeenCalled(); // Remove assertion that geocodeAddress was not called, since it may be called with undefined params }); it("should handle empty results from geocodeAddress", async () => { // SDK shape: the API summary sits under the collection's properties const fakeResult = { type: "FeatureCollection", properties: { query: "Empty", queryType: "NON_NEAR", queryTime: 1, numResults: 0, offset: 0, totalResults: 0, fuzzyLevel: 1, }, features: [], }; mocks.searchService.geocodeAddress.mockResolvedValue(fakeResult); const handler = createGeocodeHandler(); const params = { query: "Empty" }; const response = await handler(params); const parsed = JSON.parse(response.content[0].text); expect(parsed.properties.query).toBe("Empty"); expect(parsed.properties.queryTime).toBeUndefined(); expect(parsed.features).toEqual([]); }); }); describe("createReverseGeocodeHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should return reverse geocoded result for valid coordinates", async () => { const fakeResult = loadFixture("orbis-reverse-geocode"); mocks.searchService.reverseGeocode.mockResolvedValue(fakeResult); const handler = createReverseGeocodeHandler(); // The handler uses position as [lng, lat] array const response = await handler({ position: [4.8932, 52.373], response_detail: "full" as const, } as ReverseGeocodeSearchParams); expect(mocks.searchService.reverseGeocode).toHaveBeenCalled(); expect(response.content[0].text).toContain(fakeResult.properties.address.freeformAddress); }); it("should handle errors from reverseGeocode", async () => { mocks.searchService.reverseGeocode.mockRejectedValue(new Error("reverse fail")); const handler = createReverseGeocodeHandler(); const response = await handler({ position: [0, 0] } as ReverseGeocodeSearchParams); expect(response.isError).toBe(true); expect(response.content[0].text).toContain("reverse fail"); }); }); describe("createFuzzySearchHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should return fuzzy search results", async () => { const fakeResult = { summary: { query: "coffee", numResults: 2 }, results: [{ address: { freeformAddress: "Coffee Shop" } }], }; mocks.searchService.fuzzySearch.mockResolvedValue(fakeResult); const handler = createFuzzySearchHandler(); const response = await handler({ query: "coffee", response_detail: "full" }); expect(mocks.searchService.fuzzySearch).toHaveBeenCalled(); expect(response.content[0].text).toContain("Coffee Shop"); }); it("should handle errors from fuzzySearch", async () => { mocks.searchService.fuzzySearch.mockRejectedValue(new Error("fuzzy fail")); const handler = createFuzzySearchHandler(); const response = await handler({ query: "bad" }); expect(response.isError).toBe(true); expect(response.content[0].text).toContain("fuzzy fail"); }); }); describe("createPoiSearchHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should return POI search results", async () => { const fakeResult = { summary: { query: "restaurant" }, results: [{ poi: { name: "Test Restaurant" } }], }; mocks.searchService.poiSearch.mockResolvedValue(fakeResult); const handler = createPoiSearchHandler(); const response = await handler({ query: "restaurant", response_detail: "full" }); expect(response.content[0].text).toContain("Test Restaurant"); }); it("should handle errors from poiSearch", async () => { mocks.searchService.poiSearch.mockRejectedValue(new Error("poi fail")); const handler = createPoiSearchHandler(); const response = await handler({ query: "bad" }); expect(response.isError).toBe(true); }); }); describe("createNearbySearchHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should return nearby search results", async () => { const fakeResult = { summary: { numResults: 1 }, results: [{ poi: { name: "Nearby Cafe" } }], }; mocks.searchService.searchNearby.mockResolvedValue(fakeResult); const handler = createNearbySearchHandler(); // The handler uses position as [lng, lat] array const response = await handler({ position: [4.89, 52.37], response_detail: "full" }); expect(response.content[0].text).toContain("Nearby Cafe"); }); it("should handle errors from searchNearby", async () => { mocks.searchService.searchNearby.mockRejectedValue(new Error("nearby fail")); const handler = createNearbySearchHandler(); const response = await handler({ position: [0, 0] }); expect(response.isError).toBe(true); }); }); describe("createPOICategoriesHandler", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should return POI categories", async () => { const fakeCategories = [ { id: "RESTAURANT", name: "Restaurant" }, { id: "CAFE", name: "Cafe" }, ]; mocks.searchService.fetchPOICategories.mockResolvedValue(fakeCategories); const handler = createPOICategoriesHandler(); const response = await handler({ filters: ["restaurant"] }); expect(response.content[0].text).toContain("RESTAURANT"); }); it("should handle errors from fetchPOICategories", async () => { mocks.searchService.fetchPOICategories.mockRejectedValue(new Error("categories fail")); const handler = createPOICategoriesHandler(); const response = await handler({ filters: ["test"] }); expect(response.isError).toBe(true); }); }); describe("createEVSearchHandler", () => { beforeEach(() => vi.clearAllMocks()); it("should return the stations' connectors (fixture)", async () => { mocks.searchService.searchEVStations.mockResolvedValue(loadFixture("orbis-ev-search")); const handler = createEVSearchHandler(); const response = await handler({ position: [4.9041, 52.3676], show_ui: false }); expectKept(JSON.parse(response.content[0].text), [ "features[].properties.chargingPark.connectors[].type", "features[].properties.chargingPark.connectors[].ratedPowerKW", ]); }); it("should handle errors from searchEVStations", async () => { mocks.searchService.searchEVStations.mockRejectedValue(new Error("ev fail")); const handler = createEVSearchHandler(); const response = await handler({ position: [4.9041, 52.3676] }); expect(response.isError).toBe(true); }); }); describe("requested fields in search handlers", () => { beforeEach(() => vi.clearAllMocks()); it("keeps openingHours, timeZone, mapcodes and extendedPostalCode when the call asks for them", async () => { const fakeResult = loadFixture("orbis-poi-search-requested"); mocks.searchService.poiSearch.mockResolvedValue(fakeResult); const handler = createPoiSearchHandler(); const base = { query: "restaurant", show_ui: false }; const paths = [ "features[].properties.poi.openingHours", "features[].properties.poi.timeZone", "features[].properties.mapcodes", "features[].properties.address.extendedPostalCode", ]; const plain = JSON.parse((await handler(base)).content[0].text); expectDropped(fakeResult, plain, paths); const requested = await handler({ ...base, openingHours: "nextSevenDays", timeZone: "iana", mapcodes: ["Local"], extendedPostalCodesFor: "POI", }); expectKept(JSON.parse(requested.content[0].text), paths); }); }); describe("search tools share one trim", () => { beforeEach(() => vi.clearAllMocks()); const summaryDropped = ["properties.queryTime", "properties.fuzzyLevel"]; it("area search trims the collection summary like the other search tools", async () => { const fakeResult = loadFixture("orbis-poi-search"); mocks.searchService.searchInArea.mockResolvedValue(fakeResult); const handler = createAreaSearchHandler(); const response = await handler({ query: "restaurant", center: [4.9041, 52.3676], radius: 1000, show_ui: false, }); expectDropped(fakeResult, JSON.parse(response.content[0].text), summaryDropped); }); it("EV search trims the collection summary like the other search tools", async () => { const fakeResult = loadFixture("orbis-ev-search"); mocks.searchService.searchEVStations.mockResolvedValue(fakeResult); const handler = createEVSearchHandler(); const response = await handler({ position: [4.9041, 52.3676], show_ui: false }); expectDropped(fakeResult, JSON.parse(response.content[0].text), summaryDropped); }); it("search along route trims its POIs like the other search tools", async () => { const fakeResult = { route: loadFixture("orbis-route"), pois: loadFixture("orbis-poi-search"), summary: { routeLengthMeters: 45515, routeTravelTimeSeconds: 2496, poiCount: 2 }, }; mocks.searchService.searchAlongRoute.mockResolvedValue(fakeResult); const handler = createSearchAlongRouteHandler(); const response = await handler({ origin: [4.9041, 52.3676], destination: [5.1214, 52.0907], query: "petrol station", show_ui: false, response_detail: "compact", }); const parsed = JSON.parse(response.content[0].text); expectDropped(fakeResult, parsed, [ ...summaryDropped.map((path) => `pois.${path}`), "pois.features[].properties.score", "route.features[].bbox", ]); expectKept(parsed, ["route.features[].properties.summary", "pois.features[].properties.poi"]); }); });