Files
craftvia/scripts/test-planung-geocode.ts
T
msolarczekandClaude Opus 5 213bcca3a1 L13 Planung: Geocoding der Objekte über OpenStreetMap Nominatim
Provider-Interface (nominatim|none), strukturierte Suche mit User-Agent und Accept-Language, Drosselung 1/s je Prozess, Job geocode-site (Worker: Concurrency 1 + Limiter), Cache am Objekt, manuelle Koordinaten bleiben, Auslöser Anlage/Adressänderung/Import-Bestätigung nur per Queue, Backfill-Skript, Env-Beispiele. Tests mit Fake-Provider, Nominatim nie aufgerufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:13:26 +02:00

179 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// L13 Planung — Geocoding: Normalisierung/Änderungserkennung, Nominatim-Anfrage (URL, User-Agent,
// Accept-Language, Auswertung, Fehler) mit injiziertem fetch, Drosselung 1/s mit Fake-Uhr, Processor mit
// Fake-Provider (Cache bei unveränderter Adresse, not_found, failed → Retry, manuelle Koordinaten,
// Provider none, Mandantentrennung), Hook beim Anlegen ohne Inline-Aufruf. Nominatim wird NIE aufgerufen.
// Lauf: npx tsx scripts/test-planung-geocode.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
import { geocodingConfig, mapConfig } from "../src/server/services/geo/config";
import { requestSiteGeocoding } from "../src/server/services/geo/dispatch";
import { geocodeSite } from "../src/server/services/geo/geocode-site";
import { buildNominatimUrl, createNominatimProvider } from "../src/server/services/geo/nominatim";
import { addressChanged, geocodeQueryFor } from "../src/server/services/geo/normalize";
import { GeocodingError, setGeocodingProvider, type GeocodeAddress, type GeocodingProvider } from "../src/server/services/geo/provider";
import { createIntervalLimiter } from "../src/server/services/geo/rate-limit";
import { process as geocodeProcessor } from "../src/server/jobs/processors/geocode-site";
import { PROCESSORS } from "../src/server/jobs/processors";
import { createSite } from "../src/server/services/sites/sites";
const SLUG_A = "zz-planung-geo-a";
const SLUG_B = "zz-planung-geo-b";
// Guard: any request to a Nominatim host fails the suite.
let nominatimHits = 0;
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (/nominatim|openstreetmap/i.test(url)) {
nominatimHits++;
throw new Error("Nominatim must never be called from tests");
}
return realFetch(input, init);
}) as typeof fetch;
function fakeProvider(answer: (a: GeocodeAddress) => { status: "ok"; latitude: number; longitude: number } | { status: "not_found" } | "throw") {
const calls: GeocodeAddress[] = [];
const provider: GeocodingProvider = {
name: "fake",
async geocode(a) {
calls.push(a);
const r = answer(a);
if (r === "throw") throw new GeocodingError("http 503");
return r;
},
};
return { provider, calls };
}
runSuite("L13 Planung – Geocoding", [SLUG_A, SLUG_B], async () => {
section("Normalisierung & Änderungserkennung");
const addr = { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" };
ok(geocodeQueryFor(addr) === "hafenstraße 12|20457|hamburg|de", `normierte Adresse (${geocodeQueryFor(addr)})`);
ok(geocodeQueryFor({ ...addr, street: " HAFENSTRASSE ".replace("SS", "ß"), city: "hamburg " }) === geocodeQueryFor(addr), "Groß-/Kleinschreibung und Leerzeichen egal");
ok(geocodeQueryFor({ street: "Hafenstraße", houseNumber: "12" }) === null, "ohne PLZ und Ort → nicht verortbar");
ok(!addressChanged(addr, { ...addr, city: "HAMBURG" }) && addressChanged(addr, { ...addr, houseNumber: "14" }), "nur relevante Adressänderungen zählen");
section("Nominatim-Anfrage (ohne Netz)");
const url = buildNominatimUrl("https://nominatim.example/", addr);
ok(
url.pathname === "/search" && url.searchParams.get("format") === "jsonv2" && url.searchParams.get("limit") === "1" && url.searchParams.get("street") === "12 Hafenstraße" && url.searchParams.get("postalcode") === "20457" && url.searchParams.get("city") === "Hamburg" && url.searchParams.get("countrycodes") === "de",
`strukturierte Suche (${url.search})`,
);
const cfg = geocodingConfig({ APP_BASE_URL: "https://app.craftvia.example" });
ok(/^Craftvia\/\d+\.\d+\.\d+ \(\+https:\/\/app\.craftvia\.example\)$/.test(cfg.userAgent) && cfg.url === "https://nominatim.openstreetmap.org" && cfg.provider === "nominatim", `Standard-User-Agent „${cfg.userAgent}“`);
ok(geocodingConfig({ GEOCODING_USER_AGENT: "Betrieb/1 (+mailto:it@example.org)", GEOCODING_PROVIDER: "none", GEOCODING_URL: "https://geo.example/" }).userAgent === "Betrieb/1 (+mailto:it@example.org)", "User-Agent per GEOCODING_USER_AGENT überschreibbar");
ok(geocodingConfig({ GEOCODING_PROVIDER: "none" }).provider === "none" && geocodingConfig({ GEOCODING_URL: "https://geo.example/" }).url === "https://geo.example", "Provider none / eigene GEOCODING_URL");
ok(mapConfig({}).tileUrl === "https://tile.openstreetmap.org/{z}/{x}/{y}.png" && mapConfig({}).attribution.includes("OpenStreetMap"), "Kartenkacheln: OSM-Standard mit Namensnennung");
const seen: { url: string; headers: Headers }[] = [];
let reply: () => Response = () => Response.json([{ lat: "53.5413", lon: "9.9849" }]);
const acquired: number[] = [];
const nominatim = createNominatimProvider(
{ url: "https://nominatim.example", userAgent: "Craftvia/test (+https://app.example)", timeoutMs: 1000 },
{
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
seen.push({ url: String(input), headers: new Headers(init?.headers) });
return reply();
}) as typeof fetch,
limiter: { acquire: async () => (acquired.push(Date.now()), Date.now()) },
},
);
const hit = await nominatim.geocode(addr);
ok(hit.status === "ok" && hit.latitude === 53.5413 && hit.longitude === 9.9849, "Treffer ausgewertet");
ok(seen[0]?.headers.get("user-agent") === "Craftvia/test (+https://app.example)" && seen[0].headers.get("accept-language") === "de", "User-Agent und Accept-Language de gesetzt");
ok(acquired.length === 1, "jede Anfrage läuft über den Drosselungs-Limiter");
reply = () => Response.json([]);
ok((await nominatim.geocode(addr)).status === "not_found", "leere Antwort → not_found");
reply = () => new Response("busy", { status: 503 });
let threw = false;
try {
await nominatim.geocode(addr);
} catch (err) {
threw = err instanceof GeocodingError;
}
ok(threw, "HTTP-Fehler → GeocodingError (wiederholbar)");
section("Drosselung 1 Anfrage/s (Fake-Uhr)");
let clock = 10_000;
const sleeps: number[] = [];
const limiter = createIntervalLimiter(1000, { now: () => clock, sleep: async (ms) => void sleeps.push(ms) });
const slots = [await limiter.acquire(), await limiter.acquire(), await limiter.acquire()];
ok(slots[0] === 10_000 && slots[1] === 11_000 && slots[2] === 12_000 && sleeps.join(",") === "1000,2000", `Slots im Sekundenabstand (${slots.join(",")}), Wartezeiten ${sleeps.join(",")}`);
clock = 20_000;
ok((await limiter.acquire()) === 20_000 && sleeps.length === 2, "nach Pause sofort wieder frei");
section("geocodeSite mit Fake-Provider");
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const fake = fakeProvider((a) => (a.city === "Nirgendwo" ? { status: "not_found" } : { status: "ok", latitude: 53.55, longitude: a.houseNumber === "14" ? 10.01 : 10.0 }));
setGeocodingProvider(fake.provider);
const site = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo 1", ...addr } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 1, "neue Adresse → ok, 1 Provider-Aufruf");
const s1 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s1.latitude === 53.55 && s1.longitude === 10.0 && s1.geocodeStatus === "ok" && s1.geocodeQuery === geocodeQueryFor(addr) && !!s1.geocodedAt, "Koordinaten, Status, Adresse und Zeitpunkt am Objekt gespeichert");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "site", entityId: site.id, action: "update" } })) === 1, "Koordinatenänderung auditiert");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "unveränderte Adresse → Cache, kein weiterer Aufruf");
await prisma.site.update({ where: { id: site.id }, data: { city: "hamburg " } });
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "nur Schreibweise geändert → Cache");
await prisma.site.update({ where: { id: site.id }, data: { houseNumber: "14" } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 2 && (await prisma.site.findUniqueOrThrow({ where: { id: site.id } })).longitude === 10.01, "geänderte Adresse → neu verortet");
await prisma.site.update({ where: { id: site.id }, data: { city: "Nirgendwo", postalCode: "99999" } });
ok((await geocodeSite(A.tenantId, site.id)) === "not_found" && fake.calls.length === 3, "unbekannte Adresse → not_found");
const s2 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s2.geocodeStatus === "not_found" && s2.latitude === null && s2.longitude === null, "not_found entfernt veraltete automatische Koordinaten");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 3, "not_found wird für dieselbe Adresse nicht wiederholt");
const failing = fakeProvider(() => "throw");
const siteFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Fehler", ...addr } });
ok((await geocodeSite(A.tenantId, siteFail.id, { provider: failing.provider })) === "failed" && (await prisma.site.findUniqueOrThrow({ where: { id: siteFail.id } })).geocodeStatus === "failed", "Providerfehler → Status failed");
ok((await geocodeSite(A.tenantId, siteFail.id)) === "ok", "failed wird beim nächsten Lauf erneut versucht");
const manual = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo manuell", ...addr, latitude: 50.1, longitude: 8.6 } });
const callsBefore = fake.calls.length;
ok((await geocodeSite(A.tenantId, manual.id)) === "manual" && fake.calls.length === callsBefore, "manuelle Koordinaten → kein Aufruf");
const m = await prisma.site.findUniqueOrThrow({ where: { id: manual.id } });
ok(m.latitude === 50.1 && m.longitude === 8.6 && m.geocodeStatus === "skipped", "manuelle Koordinaten bleiben unverändert (Status skipped)");
const none = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo none", ...addr } });
ok((await geocodeSite(A.tenantId, none.id, { provider: null })) === "skipped" && (await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).latitude === null, "Provider none → skipped, ohne Ortsangabe, kein Fehler");
const noAddr = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo ohne Adresse", street: "Irgendwo" } });
ok((await geocodeSite(A.tenantId, noAddr.id)) === "skipped" && fake.calls.length === callsBefore, "unvollständige Adresse → skipped ohne Aufruf");
const bCalls = fake.calls.length;
ok((await geocodeSite(B.tenantId, none.id)) === "missing" && fake.calls.length === bCalls, "Mandant B: Objekt von A nicht gefunden, kein Aufruf");
ok((await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).geocodeStatus === "skipped", "Objekt von A unverändert");
section("Processor & Hook");
ok(typeof PROCESSORS["geocode-site"] === "function", "Processor geocode-site registriert");
const viaJob = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job", ...addr } });
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJob.id });
ok((await prisma.site.findUniqueOrThrow({ where: { id: viaJob.id } })).geocodeStatus === "ok", "Processor verortet das Objekt");
setGeocodingProvider(failing.provider);
const viaJobFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job Fehler", ...addr } });
let jobThrew = false;
try {
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJobFail.id });
} catch {
jobThrew = true;
}
ok(jobThrew, "Processor wirft bei failed → BullMQ wiederholt");
setGeocodingProvider(fake.provider);
const prev = process.env.GEOCODING_PROVIDER;
process.env.GEOCODING_PROVIDER = "none";
ok((await requestSiteGeocoding(A.tenantId, viaJob.id)) === false, "GEOCODING_PROVIDER=none → kein Job");
process.env.GEOCODING_PROVIDER = "nominatim";
const hookCalls = fake.calls.length;
const created = await createSite(A.ctx.backoffice, { customerId: A.customerId, name: "Geo Hook", street: "Am Kaiserkai", houseNumber: "1", postalCode: "20457", city: "Hamburg" });
await new Promise((resolve) => setTimeout(resolve, 100));
ok(!!created.id && fake.calls.length === hookCalls, "Anlage eines Objekts: Verortung nur per Queue, nie inline im Request");
if (prev === undefined) delete process.env.GEOCODING_PROVIDER;
else process.env.GEOCODING_PROVIDER = prev;
setGeocodingProvider(undefined);
ok(nominatimHits === 0, "Nominatim wurde in keinem Test aufgerufen");
});