import { describe, it, expect, vi, afterEach } from "vitest"; import { NominatimGeocodingProvider, GoogleGeocodingProvider, resolveGeocodingProvider, geocodeBatch, type FetchLike, } from "../services/geocoding.js"; /** Builds a fake fetch returning a single JSON body, recording the called URLs. */ function fakeFetch( body: unknown, init: { ok?: boolean; status?: number; statusText?: string } = {} ): { fetchImpl: FetchLike; calls: string[] } { const calls: string[] = []; const fetchImpl: FetchLike = async (url) => { calls.push(url); return { ok: init.ok ?? true, status: init.status ?? 200, statusText: init.statusText ?? "OK", json: async () => body, }; }; return { fetchImpl, calls }; } /** Virtual clock whose `sleep` advances `now`, so throttle timing is deterministic. */ function fakeClock() { const state = { t: 0 }; const sleeps: number[] = []; return { now: () => state.t, sleep: async (ms: number) => { sleeps.push(ms); state.t += ms; }, sleeps, }; } const NOMINATIM_ROW = { lat: "40.7128", lon: "-74.0060", display_name: "New York, NY, USA", }; describe("NominatimGeocodingProvider", () => { it("parses the top match into a GeocodeResult", async () => { const { fetchImpl, calls } = fakeFetch([NOMINATIM_ROW]); const provider = new NominatimGeocodingProvider({ fetchImpl }); const result = await provider.geocode("123 Main St"); expect(result).toEqual({ latitude: 40.7128, longitude: -74.006, formattedAddress: "New York, NY, USA", provider: "nominatim", }); expect(calls).toHaveLength(1); expect(calls[0]).toContain("/search"); expect(calls[0]).toContain("q=123+Main+St"); expect(calls[0]).toContain("format=jsonv2"); expect(calls[0]).toContain("limit=1"); }); it("returns null for an empty result set", async () => { const { fetchImpl } = fakeFetch([]); const provider = new NominatimGeocodingProvider({ fetchImpl }); expect(await provider.geocode("nowhere at all")).toBeNull(); }); it("returns null for a blank address without calling fetch", async () => { const { fetchImpl, calls } = fakeFetch([NOMINATIM_ROW]); const provider = new NominatimGeocodingProvider({ fetchImpl }); expect(await provider.geocode(" ")).toBeNull(); expect(calls).toHaveLength(0); }); it("throws on a non-OK HTTP response", async () => { const { fetchImpl } = fakeFetch("rate limited", { ok: false, status: 429, statusText: "Too Many Requests", }); const provider = new NominatimGeocodingProvider({ fetchImpl }); await expect(provider.geocode("123 Main St")).rejects.toThrow( /Nominatim geocoding failed: 429/ ); }); it("sends the configured User-Agent and honors a custom base URL", async () => { const calls: Array<{ url: string; headers?: Record }> = []; const fetchImpl: FetchLike = async (url, opts) => { calls.push({ url, headers: opts?.headers }); return { ok: true, status: 200, statusText: "OK", json: async () => [NOMINATIM_ROW] }; }; const provider = new NominatimGeocodingProvider({ fetchImpl, baseUrl: "https://nominatim.example.com/", userAgent: "TestAgent/9.9", }); await provider.geocode("123 Main St"); expect(calls[0]!.url).toContain("https://nominatim.example.com/search"); expect(calls[0]!.headers?.["User-Agent"]).toBe("TestAgent/9.9"); }); it("throttles to ~1 req/sec across consecutive calls (first call not delayed)", async () => { const clock = fakeClock(); const { fetchImpl } = fakeFetch([NOMINATIM_ROW]); const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 1000, now: clock.now, sleep: clock.sleep, }); await provider.geocode("a"); await provider.geocode("b"); await provider.geocode("c"); // First request immediate; each subsequent waits the full interval. expect(clock.sleeps).toEqual([1000, 1000]); }); }); describe("GoogleGeocodingProvider", () => { const GOOGLE_OK = { status: "OK", results: [ { formatted_address: "1600 Amphitheatre Pkwy, Mountain View, CA", geometry: { location: { lat: 37.4224, lng: -122.0842 } }, }, ], }; it("parses the first result into a GeocodeResult", async () => { const { fetchImpl, calls } = fakeFetch(GOOGLE_OK); const provider = new GoogleGeocodingProvider("test-key", { fetchImpl }); const result = await provider.geocode("1600 Amphitheatre Pkwy"); expect(result).toEqual({ latitude: 37.4224, longitude: -122.0842, formattedAddress: "1600 Amphitheatre Pkwy, Mountain View, CA", provider: "google", }); expect(calls[0]).toContain("key=test-key"); expect(calls[0]).toContain("address=1600+Amphitheatre+Pkwy"); }); it("returns null on ZERO_RESULTS", async () => { const { fetchImpl } = fakeFetch({ status: "ZERO_RESULTS", results: [] }); const provider = new GoogleGeocodingProvider("test-key", { fetchImpl }); expect(await provider.geocode("nowhere")).toBeNull(); }); it("throws on an API error status with the error message", async () => { const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "The provided API key is invalid.", }); const provider = new GoogleGeocodingProvider("bad-key", { fetchImpl }); await expect(provider.geocode("123 Main St")).rejects.toThrow( /Google geocoding error: REQUEST_DENIED: The provided API key is invalid\./ ); }); it("returns null for a blank address without calling fetch", async () => { const { fetchImpl, calls } = fakeFetch(GOOGLE_OK); const provider = new GoogleGeocodingProvider("test-key", { fetchImpl }); expect(await provider.geocode("")).toBeNull(); expect(calls).toHaveLength(0); }); it("rejects construction with an empty API key", () => { expect(() => new GoogleGeocodingProvider("")).toThrow(/non-empty API key/); }); }); describe("resolveGeocodingProvider", () => { const originalEnv = process.env.GOOGLE_MAPS_API_KEY; afterEach(() => { if (originalEnv === undefined) delete process.env.GOOGLE_MAPS_API_KEY; else process.env.GOOGLE_MAPS_API_KEY = originalEnv; }); it("defaults to Nominatim when provider is unset", () => { const provider = resolveGeocodingProvider(null); expect(provider.name).toBe("nominatim"); }); it("returns Nominatim for an explicit nominatim setting", () => { const provider = resolveGeocodingProvider({ routeOptimizationProvider: "nominatim", }); expect(provider.name).toBe("nominatim"); }); it("returns Google and decrypts the stored key when provider is google", () => { const decrypt = vi.fn().mockReturnValue("decrypted-google-key"); const provider = resolveGeocodingProvider( { routeOptimizationProvider: "google", googleMapsApiKey: "enc:abc" }, { decrypt } ); expect(provider.name).toBe("google"); expect(decrypt).toHaveBeenCalledWith("enc:abc"); }); it("falls back to Nominatim (with a warning) when google has no usable key", () => { delete process.env.GOOGLE_MAPS_API_KEY; const warn = vi.fn(); const provider = resolveGeocodingProvider( { routeOptimizationProvider: "google", googleMapsApiKey: null }, { warn } ); expect(provider.name).toBe("nominatim"); expect(warn).toHaveBeenCalledOnce(); }); it("falls back to Nominatim (with a warning) when decryption fails", () => { const decrypt = vi.fn().mockImplementation(() => { throw new Error("bad ciphertext"); }); const warn = vi.fn(); delete process.env.GOOGLE_MAPS_API_KEY; const provider = resolveGeocodingProvider( { routeOptimizationProvider: "google", googleMapsApiKey: "enc:corrupt" }, { decrypt, warn } ); expect(provider.name).toBe("nominatim"); expect(warn).toHaveBeenCalled(); }); it("uses GOOGLE_MAPS_API_KEY env var as a fallback key source", () => { process.env.GOOGLE_MAPS_API_KEY = "env-key"; const provider = resolveGeocodingProvider({ routeOptimizationProvider: "google", }); expect(provider.name).toBe("google"); }); }); describe("geocodeBatch", () => { it("geocodes items in order and preserves keys", async () => { const { fetchImpl } = fakeFetch([NOMINATIM_ROW]); const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0, }); const outcomes = await geocodeBatch( [ { key: "c1", address: "123 Main St" }, { key: "c2", address: "456 Oak Ave" }, ], provider ); expect(outcomes.map((o) => o.key)).toEqual(["c1", "c2"]); expect(outcomes[0]!.result?.latitude).toBe(40.7128); expect(outcomes[1]!.error).toBeUndefined(); }); it("captures per-item errors and continues the batch", async () => { let call = 0; const fetchImpl: FetchLike = async () => { call += 1; if (call === 1) { return { ok: false, status: 500, statusText: "Server Error", json: async () => "" }; } return { ok: true, status: 200, statusText: "OK", json: async () => [NOMINATIM_ROW] }; }; const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0 }); const outcomes = await geocodeBatch( [ { key: 1, address: "bad" }, { key: 2, address: "good" }, ], provider ); expect(outcomes[0]!.result).toBeNull(); expect(outcomes[0]!.error).toMatch(/500/); expect(outcomes[1]!.result?.latitude).toBe(40.7128); }); it("reports progress for each completed item", async () => { const { fetchImpl } = fakeFetch([NOMINATIM_ROW]); const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0 }); const progress: Array<[number, number]> = []; await geocodeBatch( [ { key: "a", address: "1 St" }, { key: "b", address: "2 St" }, ], provider, { onProgress: (completed, total) => progress.push([completed, total]) } ); expect(progress).toEqual([ [1, 2], [2, 2], ]); }); });