"""AZ-838 ``SatelliteProviderRouteClient`` unit tests (Epic AZ-835 C2). Covers AC-1..AC-9 of ``_docs/02_tasks/todo/AZ-838_satellite_provider_route_client.md``: * AC-1 wire shape — ``id`` / ``name`` / ``regionSizeMeters`` / ``zoomLevel`` / ``points[].lat`` / ``points[].lon`` / ``requestMaps`` / ``createTilesZip``. * AC-2 polling — happy path + budget exhaustion. * AC-3 4xx + RFC 7807 ProblemDetails → ``RouteValidationError``. * AC-4 5xx / network / timeout → ``RouteTransientError``. * AC-5 terminal failure → ``RouteTerminalFailureError``. * AC-6 pre-emptive validation — every per-field rule mirrored from ``CreateRouteRequestValidator.cs`` (the spec's ``points <= 100`` / ``zoomLevel in 15..18`` were narrower than the actual server validator; the client tracks the SERVER bounds so it doesn't reject inputs the server would accept — see status-summary note in batch 107 cycle 3 report). * AC-7 dry-run / ``build_planned_payload`` — assembles body + sha256 without HTTP. Tests use :class:`httpx.MockTransport` for deterministic HTTP, list- backed log handlers for log capture, and a fake ``sleep`` so the poll loop runs in O(0) wall time. The integration test (AC-10) is gated on ``RUN_E2E=1`` and lives outside this file. """ from __future__ import annotations import json import logging import math import uuid from pathlib import Path import httpx import pytest from gps_denied_onboard.components.c11_tile_manager.errors import ( RouteTerminalFailureError, RouteTransientError, RouteValidationError, SatelliteProviderRouteError, ) from gps_denied_onboard.components.c11_tile_manager.route_client import ( RouteSeedResult, SatelliteProviderRouteClient, ) from gps_denied_onboard.replay_input.tlog_route import RouteSpec _BASE_URL = "https://parent-suite.test" _JWT = "test-jwt-az838" _ROUTE_CREATE_PATH = "/api/satellite/route" _ROUTE_STATUS_PATH_PREFIX = "/api/satellite/route/" _INVENTORY_PATH = "/api/satellite/tiles/inventory" # ---------------------------------------------------------------------- # Fixtures # ---------------------------------------------------------------------- def _make_spec( waypoints: tuple[tuple[float, float], ...] | None = None, *, region_size: float = 500.0, source: Path | None = None, ) -> RouteSpec: return RouteSpec( waypoints=waypoints or ( (49.5731, 36.4456), (49.5750, 36.4470), (49.5770, 36.4490), ), suggested_region_size_meters=region_size, source_tlog=source or Path("tests/fixtures/derkachi_c6/derkachi.tlog"), source_segment=(0, 99), total_distance_meters=2500.0, ) def _make_log_capture() -> tuple[logging.Logger, list[logging.LogRecord]]: records: list[logging.LogRecord] = [] class _Handler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: records.append(record) logger = logging.getLogger(f"test_az838_{id(records)}") logger.handlers.clear() logger.addHandler(_Handler()) logger.setLevel(logging.DEBUG) logger.propagate = False return logger, records def _build_client( transport: httpx.MockTransport, *, poll_max_attempts: int = 5, poll_interval_s: float = 0.0001, sleeps: list[float] | None = None, logger: logging.Logger | None = None, ) -> tuple[SatelliteProviderRouteClient, httpx.Client]: http_client = httpx.Client(transport=transport, base_url=_BASE_URL) sleeps_target = sleeps if sleeps is not None else [] client = SatelliteProviderRouteClient( base_url=_BASE_URL, jwt=_JWT, request_timeout_s=5.0, poll_interval_s=poll_interval_s, poll_max_attempts=poll_max_attempts, http_client=http_client, sleep=sleeps_target.append, logger=logger, ) return client, http_client def _route_status_url(route_id: uuid.UUID) -> str: return f"{_BASE_URL}{_ROUTE_STATUS_PATH_PREFIX}{route_id}" # ---------------------------------------------------------------------- # Happy path (AC-1, AC-2 happy) # ---------------------------------------------------------------------- def test_seed_route_happy_path_posts_canonical_wire_shape() -> None: # Arrange spec = _make_spec() captured_post: dict = {} captured_inventory: dict = {} poll_calls: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH: captured_post["headers"] = dict(request.headers) captured_post["body"] = json.loads(request.content) return httpx.Response(200, json={"status": "submitted"}) if ( request.method == "GET" and request.url.path.startswith(_ROUTE_STATUS_PATH_PREFIX) ): poll_calls.append(request.url.path) return httpx.Response( 200, json={"status": "completed", "mapsReady": True}, ) if request.method == "POST" and request.url.path == _INVENTORY_PATH: captured_inventory["body"] = json.loads(request.content) tiles = json.loads(request.content)["tiles"] return httpx.Response( 200, json={ "results": [ {**t, "present": True, "etag": "abc"} for t in tiles ] }, ) return httpx.Response(404) transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act result = client.seed_route(spec, name="route-test", zoom_level=18) # Assert body = captured_post["body"] assert uuid.UUID(body["id"]).int != 0 assert body["name"] == "route-test" assert body["regionSizeMeters"] == 500.0 assert body["zoomLevel"] == 18 assert body["requestMaps"] is True assert body["createTilesZip"] is False assert body["points"] == [ {"lat": 49.5731, "lon": 36.4456}, {"lat": 49.5750, "lon": 36.4470}, {"lat": 49.5770, "lon": 36.4490}, ] assert captured_post["headers"]["authorization"] == f"Bearer {_JWT}" assert isinstance(result, RouteSeedResult) assert result.route_id == uuid.UUID(body["id"]) assert result.terminal_status == "completed" assert result.maps_ready is True assert result.tile_count > 0 assert result.elapsed_ms >= 0 assert len(result.submitted_payload_sha256) == 64 assert len(poll_calls) == 1 inventory_tiles = captured_inventory["body"]["tiles"] assert all(t["z"] == 18 for t in inventory_tiles) finally: http_client.close() # ---------------------------------------------------------------------- # AC-2: poll budget exhaustion # ---------------------------------------------------------------------- def test_seed_route_polls_until_maps_ready() -> None: # Arrange poll_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH: return httpx.Response(200, json={"status": "submitted"}) if request.method == "GET": poll_count["n"] += 1 if poll_count["n"] < 3: return httpx.Response( 200, json={"status": "processing", "mapsReady": False}, ) return httpx.Response( 200, json={"status": "completed", "mapsReady": True}, ) if request.method == "POST" and request.url.path == _INVENTORY_PATH: tiles = json.loads(request.content)["tiles"] return httpx.Response( 200, json={"results": [{**t, "present": True} for t in tiles]}, ) return httpx.Response(404) transport = httpx.MockTransport(handler) sleeps: list[float] = [] client, http_client = _build_client( transport, poll_max_attempts=10, sleeps=sleeps ) try: # Act result = client.seed_route(_make_spec()) # Assert assert result.maps_ready is True assert poll_count["n"] == 3 assert len(sleeps) == 2 finally: http_client.close() def test_seed_route_raises_terminal_when_budget_exhausted() -> None: # Arrange poll_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH: return httpx.Response(200, json={"status": "submitted"}) if request.method == "GET": poll_count["n"] += 1 return httpx.Response( 200, json={"status": "processing", "mapsReady": False}, ) return httpx.Response(404) transport = httpx.MockTransport(handler) client, http_client = _build_client(transport, poll_max_attempts=4) try: # Act + Assert with pytest.raises(RouteTerminalFailureError) as exc_info: client.seed_route(_make_spec()) assert poll_count["n"] == 4 assert exc_info.value.route_id is not None finally: http_client.close() # ---------------------------------------------------------------------- # AC-3: 4xx + RFC 7807 ProblemDetails # ---------------------------------------------------------------------- def test_seed_route_4xx_problem_details_to_validation_error() -> None: # Arrange problem_body = { "type": "https://example.com/probs/validation", "title": "One or more validation errors occurred.", "status": 400, "errors": { "regionSizeMeters": [ "must be between 100 and 10000 meters." ], "points[0].lat": ["must be in [-90, 90]"], }, } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(400, json=problem_body) transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act + Assert with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec()) assert exc_info.value.http_status == 400 assert "regionSizeMeters" in exc_info.value.field_errors assert exc_info.value.field_errors["points[0].lat"] == [ "must be in [-90, 90]" ] finally: http_client.close() def test_seed_route_4xx_without_problem_details_still_raises_validation() -> None: # Arrange def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(403, text="forbidden") transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act + Assert with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec()) assert exc_info.value.http_status == 403 assert exc_info.value.field_errors == {} finally: http_client.close() # ---------------------------------------------------------------------- # AC-4: 5xx / network / timeout # ---------------------------------------------------------------------- def test_seed_route_5xx_to_transient_error() -> None: # Arrange def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(503, text="service unavailable") transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act + Assert with pytest.raises(RouteTransientError): client.seed_route(_make_spec()) finally: http_client.close() def test_seed_route_network_error_preserves_cause() -> None: # Arrange def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("simulated TCP refused") transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act with pytest.raises(RouteTransientError) as exc_info: client.seed_route(_make_spec()) # Assert assert isinstance(exc_info.value.__cause__, httpx.ConnectError) finally: http_client.close() def test_seed_route_timeout_preserves_cause() -> None: # Arrange def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("simulated read timeout") transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act + Assert with pytest.raises(RouteTransientError) as exc_info: client.seed_route(_make_spec()) assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) finally: http_client.close() # ---------------------------------------------------------------------- # AC-5: terminal failure # ---------------------------------------------------------------------- def test_seed_route_terminal_failure_status_raises() -> None: # Arrange failure_payload = { "status": "failed", "mapsReady": False, "error": "tile fetch exhausted retries", } def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH: return httpx.Response(200, json={"status": "submitted"}) if request.method == "GET": return httpx.Response(200, json=failure_payload) return httpx.Response(404) transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act with pytest.raises(RouteTerminalFailureError) as exc_info: client.seed_route(_make_spec()) # Assert assert exc_info.value.detail == failure_payload assert exc_info.value.route_id is not None finally: http_client.close() # ---------------------------------------------------------------------- # AC-6: pre-emptive validation (pre-POST) # ---------------------------------------------------------------------- def _build_no_http_client() -> SatelliteProviderRouteClient: """Build a client whose transport rejects every HTTP call. Used to verify pre-emptive validation rejects inputs BEFORE any HTTP request leaves the client. """ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError( f"unexpected HTTP request after pre-emptive validation: " f"{request.method} {request.url}" ) transport = httpx.MockTransport(handler) http_client = httpx.Client(transport=transport, base_url=_BASE_URL) return SatelliteProviderRouteClient( base_url=_BASE_URL, jwt=_JWT, http_client=http_client, ) def test_preemptive_rejects_empty_points() -> None: # Arrange spec = RouteSpec( waypoints=(), suggested_region_size_meters=500.0, source_tlog=Path("tlog"), source_segment=(0, 0), total_distance_meters=0.0, ) client = _build_no_http_client() # Act + Assert with pytest.raises(RouteValidationError) as exc_info: client.seed_route(spec) assert "points" in exc_info.value.field_errors def test_preemptive_rejects_too_many_points() -> None: # Arrange — server validator caps at 500, so 501 is the trigger. spec = RouteSpec( waypoints=tuple( (49.0 + i * 1e-5, 36.0 + i * 1e-5) for i in range(501) ), suggested_region_size_meters=500.0, source_tlog=Path("tlog"), source_segment=(0, 500), total_distance_meters=10.0, ) client = _build_no_http_client() # Act + Assert with pytest.raises(RouteValidationError) as exc_info: client.seed_route(spec) assert "points" in exc_info.value.field_errors def test_preemptive_rejects_zero_region_size() -> None: client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec(), region_size_meters=0.0) assert "regionSizeMeters" in exc_info.value.field_errors def test_preemptive_rejects_oversized_region() -> None: client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec(), region_size_meters=10_001.0) assert "regionSizeMeters" in exc_info.value.field_errors def test_preemptive_rejects_oor_zoom_high() -> None: client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec(), zoom_level=23) assert "zoomLevel" in exc_info.value.field_errors def test_preemptive_rejects_oor_zoom_low() -> None: client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec(), zoom_level=-1) assert "zoomLevel" in exc_info.value.field_errors def test_preemptive_rejects_oor_lat() -> None: spec = _make_spec(waypoints=((100.0, 36.0), (49.0, 36.0))) client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(spec) assert any(k.startswith("points[") for k in exc_info.value.field_errors) def test_preemptive_rejects_oor_lon() -> None: spec = _make_spec(waypoints=((49.0, 200.0), (49.0, 36.0))) client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(spec) assert any(k.startswith("points[") for k in exc_info.value.field_errors) def test_preemptive_rejects_oversized_name() -> None: client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec(), name="x" * 201) assert "name" in exc_info.value.field_errors def test_preemptive_rejects_oversized_description() -> None: client = _build_no_http_client() with pytest.raises(RouteValidationError) as exc_info: client.seed_route(_make_spec(), description="x" * 1001) assert "description" in exc_info.value.field_errors # ---------------------------------------------------------------------- # AC-7: dry-run / build_planned_payload # ---------------------------------------------------------------------- def test_build_planned_payload_runs_without_http() -> None: # Arrange client = _build_no_http_client() # Act body, sha256 = client.build_planned_payload( _make_spec(), name="dry-run-test", zoom_level=18, ) # Assert assert body["name"] == "dry-run-test" assert body["regionSizeMeters"] == 500.0 assert body["zoomLevel"] == 18 assert body["requestMaps"] is True assert body["createTilesZip"] is False assert len(body["points"]) == 3 assert len(sha256) == 64 def test_build_planned_payload_is_deterministic_for_same_inputs() -> None: # Arrange — same name + same spec must produce the same sha256 # (route_id varies, so the body itself differs; the sha256 is over # the canonical JSON, so it varies too — assert that it's stable # WITHIN one build but distinct per call due to fresh route_id). client = _build_no_http_client() # Act body_a, sha_a = client.build_planned_payload( _make_spec(), name="same-name", zoom_level=18 ) body_b, sha_b = client.build_planned_payload( _make_spec(), name="same-name", zoom_level=18 ) # Assert assert body_a["id"] != body_b["id"] assert sha_a != sha_b def test_build_planned_payload_runs_validation() -> None: # Arrange client = _build_no_http_client() # Act + Assert — dry-run must surface OOR zoom the same as a live run with pytest.raises(RouteValidationError): client.build_planned_payload(_make_spec(), zoom_level=99) # ---------------------------------------------------------------------- # Constructor sanity # ---------------------------------------------------------------------- def test_constructor_rejects_empty_base_url() -> None: with pytest.raises(ValueError): SatelliteProviderRouteClient(base_url="", jwt="x") def test_constructor_rejects_empty_jwt() -> None: with pytest.raises(ValueError): SatelliteProviderRouteClient(base_url="https://x", jwt="") def test_constructor_rejects_nonpositive_timeout() -> None: with pytest.raises(ValueError): SatelliteProviderRouteClient( base_url="https://x", jwt="y", request_timeout_s=0.0 ) def test_constructor_rejects_nonpositive_poll_interval() -> None: with pytest.raises(ValueError): SatelliteProviderRouteClient( base_url="https://x", jwt="y", poll_interval_s=0.0 ) def test_constructor_rejects_nonpositive_poll_max_attempts() -> None: with pytest.raises(ValueError): SatelliteProviderRouteClient( base_url="https://x", jwt="y", poll_max_attempts=0 ) # ---------------------------------------------------------------------- # Error class hierarchy # ---------------------------------------------------------------------- def test_route_error_subclass_relationships() -> None: # Assert assert issubclass(RouteValidationError, SatelliteProviderRouteError) assert issubclass(RouteTransientError, SatelliteProviderRouteError) assert issubclass(RouteTerminalFailureError, SatelliteProviderRouteError) # ---------------------------------------------------------------------- # Inventory edge cases # ---------------------------------------------------------------------- def test_inventory_404_during_verify_raises_validation() -> None: # Arrange — route POST OK, polling reports ready, inventory 404s. def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH: return httpx.Response(200, json={"status": "submitted"}) if request.method == "GET": return httpx.Response( 200, json={"status": "completed", "mapsReady": True} ) if request.method == "POST" and request.url.path == _INVENTORY_PATH: return httpx.Response(404, text="not found") return httpx.Response(404) transport = httpx.MockTransport(handler) client, http_client = _build_client(transport) try: # Act + Assert with pytest.raises(RouteValidationError): client.seed_route(_make_spec()) finally: http_client.close() def test_logging_emits_structured_extra_for_submit_and_poll() -> None: # Arrange logger, records = _make_log_capture() def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH: return httpx.Response(200, json={"status": "submitted"}) if request.method == "GET": return httpx.Response( 200, json={"status": "completed", "mapsReady": True} ) if request.method == "POST" and request.url.path == _INVENTORY_PATH: tiles = json.loads(request.content)["tiles"] return httpx.Response( 200, json={"results": [{**t, "present": True} for t in tiles]} ) return httpx.Response(404) transport = httpx.MockTransport(handler) client, http_client = _build_client(transport, logger=logger) try: # Act client.seed_route(_make_spec()) # Assert — at minimum we expect submit + one poll-tick + terminal + inventory kinds = {getattr(r, "kind", None) for r in records} assert "c11.route.submit" in kinds assert "c11.route.poll.tick" in kinds assert "c11.route.poll.terminal" in kinds assert "c11.route.inventory" in kinds finally: http_client.close()