import { describe, it, expect, vi } from "vitest"; import { geocodeClient, geocodeUngeocodedClients, resolveClientGeocodingProvider, } from "../services/clientGeocoding.js"; import { NominatimGeocodingProvider, type GeocodeResult, type GeocodingProvider, } from "../services/geocoding.js"; // ─── Fakes ────────────────────────────────────────────────────────────────── /** Fake provider with a scripted geocode behaviour and a call log. */ function fakeProvider( impl: (address: string) => Promise ): GeocodingProvider & { calls: string[] } { const calls: string[] = []; return { name: "nominatim", minRequestIntervalMs: 0, calls, geocode: (address: string) => { calls.push(address); return impl(address); }, }; } const okResult = (lat: number, lng: number): GeocodeResult => ({ latitude: lat, longitude: lng, formattedAddress: "1 Main St, Anytown", provider: "nominatim", }); /** * Minimal db double recording update() set-values. `select()` chains return the * preloaded `selectQueue` shift()ed per call so different statements get * different rows (used by geocodeUngeocodedClients: count, then rows). */ function fakeDb(selectQueue: unknown[][]) { const updates: Record[] = []; const queue = [...selectQueue]; const chain = () => { const rows = queue.shift() ?? []; const proxy: Record = {}; for (const k of ["from", "where", "orderBy", "limit"]) { proxy[k] = () => proxy; } // Make the chain awaitable / iterable as the resolved rows. (proxy as { then: unknown }).then = (resolve: (v: unknown) => void) => resolve(rows); (proxy as { [Symbol.iterator]: unknown })[Symbol.iterator] = () => (rows as unknown[])[Symbol.iterator](); return proxy; }; const db = { select: () => chain(), update: () => ({ set: (vals: Record) => ({ where: () => { updates.push(vals); return { returning: async () => [] }; }, }), }), updates, }; return db as unknown as Parameters[0] & { updates: Record[]; }; } const clientRow = (over: Record = {}) => ({ id: "client-1", name: "Alice", email: "a@example.com", address: "1 Main St", latitude: null, longitude: null, geocodedAt: null, ...over, }) as unknown as Parameters[1]; // ─── geocodeClient ──────────────────────────────────────────────────────────── describe("geocodeClient", () => { it("persists coordinates and returns a geocoded outcome", async () => { const db = fakeDb([]); const provider = fakeProvider(async () => okResult(40.1, -74.2)); const outcome = await geocodeClient(db, clientRow(), provider); expect(outcome.status).toBe("geocoded"); expect(outcome.latitude).toBe(40.1); expect(outcome.longitude).toBe(-74.2); expect(outcome.geocodedAt).toBeTruthy(); expect(db.updates).toHaveLength(1); expect(db.updates[0]!.latitude).toBe(40.1); expect(db.updates[0]!.longitude).toBe(-74.2); expect(db.updates[0]!.geocodedAt).toBeInstanceOf(Date); }); it("returns no_address and does not persist when address is blank", async () => { const db = fakeDb([]); const provider = fakeProvider(async () => okResult(0, 0)); const outcome = await geocodeClient(db, clientRow({ address: " " }), provider); expect(outcome.status).toBe("no_address"); expect(provider.calls).toHaveLength(0); expect(db.updates).toHaveLength(0); }); it("returns unresolved when the provider finds no match", async () => { const db = fakeDb([]); const provider = fakeProvider(async () => null); const outcome = await geocodeClient(db, clientRow(), provider); expect(outcome.status).toBe("unresolved"); expect(outcome.message).toMatch(/could not be resolved/i); expect(db.updates).toHaveLength(0); }); it("returns error (without throwing) when the provider fails", async () => { const db = fakeDb([]); const provider = fakeProvider(async () => { throw new Error("quota exceeded"); }); const outcome = await geocodeClient(db, clientRow(), provider); expect(outcome.status).toBe("error"); expect(outcome.message).toMatch(/quota exceeded/); expect(db.updates).toHaveLength(0); }); }); // ─── geocodeUngeocodedClients ───────────────────────────────────────────────── describe("geocodeUngeocodedClients", () => { it("geocodes candidates, tallies outcomes, and reports remaining", async () => { // First select() = count query, second select() = candidate rows. const db = fakeDb([ [{ count: 5 }], [ clientRow({ id: "c1", address: "1 Main St" }), clientRow({ id: "c2", address: "2 Oak Ave" }), clientRow({ id: "c3", address: "" }), // no_address ], ]); const provider = fakeProvider(async (addr) => addr === "2 Oak Ave" ? null : okResult(1, 2) ); const summary = await geocodeUngeocodedClients(db, 50, provider); expect(summary.processed).toBe(3); expect(summary.geocoded).toBe(1); expect(summary.unresolved).toBe(1); // "2 Oak Ave" expect(summary.remaining).toBe(2); // 5 total - 3 processed expect(summary.provider).toBe("nominatim"); expect(db.updates).toHaveLength(1); // only the successful one persisted }); it("clamps the limit to the 1..500 range", async () => { const db = fakeDb([[{ count: 0 }], []]); const provider = fakeProvider(async () => okResult(1, 2)); const summary = await geocodeUngeocodedClients(db, 0, provider); expect(summary.processed).toBe(0); expect(summary.remaining).toBe(0); }); }); // ─── resolveClientGeocodingProvider ─────────────────────────────────────────── describe("resolveClientGeocodingProvider", () => { it("defaults to Nominatim when no settings row exists", async () => { const db = fakeDb([[]]); // businessSettings select -> empty const provider = await resolveClientGeocodingProvider(db); expect(provider).toBeInstanceOf(NominatimGeocodingProvider); expect(provider.name).toBe("nominatim"); }); it("defaults to Nominatim when provider is unset on settings", async () => { const db = fakeDb([[{ routeOptimizationProvider: null, googleMapsApiKey: null }]]); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const provider = await resolveClientGeocodingProvider(db); expect(provider.name).toBe("nominatim"); warn.mockRestore(); }); });