47dae603ac
Phase 1.3 of Route Optimization. Exposes the GRO-2153 geocoding service as API endpoints and wires auto-geocoding into the client mutation flow. - POST /api/clients/:clientId/geocode — geocode one client (manager-only) - POST /api/clients/geocode-batch?limit= — throttled batch geocode of un-geocoded clients, reports `remaining` so managers re-run to finish (avoids HTTP timeouts under Nominatim's 1 req/sec policy) - Auto-geocode on client create/update when an address is supplied; clearing the address drops stale coordinates - Persists latitude/longitude/geocodedAt to the clients table - Structured outcomes (geocoded | no_address | unresolved | error) with clear messages so ambiguous addresses are surfaced to groomers/managers - src/services/clientGeocoding.ts orchestrates provider resolution + persistence; unit tests cover all outcomes and batch tallying - Updated UAT_PLAYBOOK.md §4.2 — added TC-API-2.7 .. 2.17 (geocoding) Co-Authored-By: Paperclip <noreply@paperclip.ing>
193 lines
6.9 KiB
TypeScript
193 lines
6.9 KiB
TypeScript
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<GeocodeResult | null>
|
|
): 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<string, unknown>[] = [];
|
|
const queue = [...selectQueue];
|
|
const chain = () => {
|
|
const rows = queue.shift() ?? [];
|
|
const proxy: Record<string, unknown> = {};
|
|
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<string, unknown>) => ({
|
|
where: () => {
|
|
updates.push(vals);
|
|
return { returning: async () => [] };
|
|
},
|
|
}),
|
|
}),
|
|
updates,
|
|
};
|
|
return db as unknown as Parameters<typeof geocodeClient>[0] & {
|
|
updates: Record<string, unknown>[];
|
|
};
|
|
}
|
|
|
|
const clientRow = (over: Record<string, unknown> = {}) =>
|
|
({
|
|
id: "client-1",
|
|
name: "Alice",
|
|
email: "a@example.com",
|
|
address: "1 Main St",
|
|
latitude: null,
|
|
longitude: null,
|
|
geocodedAt: null,
|
|
...over,
|
|
}) as unknown as Parameters<typeof geocodeClient>[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();
|
|
});
|
|
});
|