""" server-26#298 — geocode result cache in front of the Google Geocoding API, and server-26#160 — place verification gets an anchor from the talkgroup's own name when nobody configured one. """ from unittest.mock import AsyncMock, patch import pytest import app.internal.intelligence as intel from app.config import settings from app.internal import place_verifier as pv ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0} class _Counter: def __init__(self, payload=None, exc=None): self.calls = 0 self.payload = payload self.exc = exc def patch(self): outer = self class _Resp: def raise_for_status(self): if outer.exc: raise outer.exc def json(self): return outer.payload class _Client: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def get(self, *a, **k): outer.calls += 1 return _Resp() return patch("httpx.AsyncClient", lambda *a, **k: _Client()) def _ok(lat=41.161, lng=-73.861): return {"status": "OK", "results": [{"geometry": { "location": {"lat": lat, "lng": lng}, "location_type": "ROOFTOP"}}]} @pytest.fixture(autouse=True) def _key(): with patch.object(settings, "google_maps_api_key", "test-key"): yield @pytest.mark.asyncio async def test_repeat_address_is_one_request(): c = _Counter(_ok()) with c.patch(): a = await intel._geocode_location("12 Main Street, Ossining", anchor=ANCHOR) b = await intel._geocode_location("12 main street, ossining ", anchor=ANCHOR) assert a == b == {"lat": 41.161, "lng": -73.861, "location_type": "ROOFTOP"} assert c.calls == 1 assert intel.geocode_cache_stats == {"hits": 1, "misses": 1} @pytest.mark.asyncio async def test_cached_answer_is_still_judged_against_the_caller_anchor(): c = _Counter(_ok()) with c.patch(): assert await intel._geocode_location("12 Main Street", anchor=ANCHOR) far = {"lat": 0.0, "lng": 0.0, "radius_km": 10.0} # Different bounds -> different key, so a separate request — never a # cached answer leaking past a different area's distance check. assert await intel._geocode_location("12 Main Street", anchor=far) is None assert c.calls == 2 @pytest.mark.asyncio async def test_zero_results_is_cached_but_errors_are_not(): c = _Counter({"status": "ZERO_RESULTS", "results": []}) with c.patch(): await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR) await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR) assert c.calls == 1 denied = _Counter({"status": "REQUEST_DENIED"}) with denied.patch(): await intel._geocode_location("Snowden Avenue", anchor=ANCHOR) await intel._geocode_location("Snowden Avenue", anchor=ANCHOR) assert denied.calls == 2, "a key/quota failure is not an answer about the address" boom = _Counter(_ok(), exc=RuntimeError("503")) with boom.patch(): await intel._geocode_location("Croton Point", anchor=ANCHOR) await intel._geocode_location("Croton Point", anchor=ANCHOR) assert boom.calls == 2 @pytest.mark.asyncio async def test_ttl_expiry_and_size_bound(): c = _Counter(_ok()) with c.patch(), patch.object(settings, "geocode_cache_ttl_seconds", 0): await intel._geocode_location("12 Main Street", anchor=ANCHOR) await intel._geocode_location("12 Main Street", anchor=ANCHOR) assert c.calls == 2, "TTL 0 disables the cache" c = _Counter(_ok()) with c.patch(), patch.object(settings, "geocode_cache_max_entries", 2): for street in ("A Street", "B Street", "C Street"): await intel._geocode_location(street, anchor=ANCHOR) assert len(intel._geocode_cache) == 2 # ── server-26#160 ─────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_unconfigured_channel_gets_an_anchor_from_its_talkgroup_name(): resolved = {"center": {"lat": 40.71, "lng": -74.0}, "radius_km": 25.0} seen = {} async def _geo(query, anchor): seen["query"], seen["anchor"] = query, anchor return {"lat": 40.7, "lng": -74.0} with patch.object(pv.settings, "place_verification_enabled", True), \ patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \ patch.object(intel, "_get_node_state", AsyncMock(return_value="New York")), \ patch.object(pv.area_context, "resolve_anchor", AsyncMock(return_value=resolved)) as res, \ patch.object(pv, "_geocode_in_anchor", _geo): await pv.verify("c1", "Respond to Lexington Avenue", None, ["Lexington Avenue"], {}, {}, talkgroup_name="New York City PD", node_id="node-1") res.assert_awaited_once_with({"municipality": "New York City", "state": "New York"}) assert seen["anchor"] == {"lat": 40.71, "lng": -74.0, "radius_km": 25.0} assert seen["query"] == "Lexington Avenue, New York City, New York" @pytest.mark.asyncio async def test_no_state_means_no_derived_anchor(): """'Springfield PD' alone could anchor anywhere — skip, never guess.""" with patch.object(pv.settings, "place_verification_enabled", True), \ patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \ patch.object(intel, "_get_node_state", AsyncMock(return_value="")), \ patch.object(pv, "_geocode_in_anchor") as geo: out = await pv.verify("c1", "Respond to Main Street", None, ["Main Street"], {}, {}, talkgroup_name="Springfield PD", node_id="node-1") assert out == (None, None) geo.assert_not_called() @pytest.mark.asyncio async def test_configured_place_without_anchor_still_skips(): """A place that is set but too wide (no anchor stored) keeps the load-bearing skip; the talkgroup-name fallback is only for unset areas.""" with patch.object(pv.settings, "place_verification_enabled", True), \ patch.object(pv, "_talkgroup_anchor", AsyncMock()) as derived, \ patch.object(pv, "_geocode_in_anchor") as geo: out = await pv.verify("c1", "Main Street", None, ["Main Street"], {"state": "Colorado"}, {}, talkgroup_name="Denver PD", node_id="n") assert out == (None, None) derived.assert_not_awaited() geo.assert_not_called()