mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 18:01:14 +00:00
fd52cc9b1d
Cycle-3 refactor run 02-az507 (RouteSpec relocation + module-layout
refresh + AZ-270 lint widening). Single batch of 3 tasks; epic AZ-844.
AZ-845 — Relocate RouteSpec DTO to _types/route.py (rule-9 fix):
* New canonical home: src/gps_denied_onboard/_types/route.py
(frozen+slots dataclass; full docstring carried over verbatim).
* c11_tile_manager/route_client.py imports from _types.route.
* replay_input/tlog_route.py and replay_input/__init__.py keep
re-exports for backward-compat (RouteSpec in __all__).
* 5 test files updated to import from _types.route for symmetry.
* Identity-preserving re-export verified by new test
test_az845_routespec_canonical_home_and_reexport_identity.
AZ-846 — Refresh module-layout.md cycle-3 entries:
* c11_tile_manager Internal list rewritten with all 8 internals
(alphabetised) — corrects a stale entry that referenced files
(satellite_provider_*.py) that no longer exist.
* shared/replay_input file list adds errors.py (cycle-2 carry),
tlog_ground_truth.py (cycle-2 carry), tlog_route.py (cycle-3 NEW).
* shared/_types section registers route.py with provenance line.
* Out-of-scope cycle-2 carry-overs (replay_api/, cli/render_map.py,
helpers/gps_compare.py, etc.) intentionally untouched.
AZ-847 — Widen test_az270 lint to enforce full rule-9 allow-list:
* test_ac6_only_compose_root_imports_concrete_strategies now walks
every components/<X>/*.py ImportFrom/Import and rejects anything
not in the rule-9 allow-list (own subpackage + _types + helpers
+ config/logging/fdr_client/clock + frame_source interface-only).
* Strict superset of the original AC-6 narrow check.
* Reports zero violations on the codebase post-AZ-845.
* Two principled carve-outs documented in the test docstring:
- components/<X>/bench/** path skip (measurement code legitimately
constructs production strategies via runtime_root factories).
- register_* lazy self-registration imports from
runtime_root.<X>_factory (central-registry plugin pattern).
* Both carve-outs surfaced to user via Choose A/B/C/D Risk-1
protocol; user skipped both — agent proceeded with documented
defaults. Doc-only follow-up tracked in
_docs/_process_leftovers/2026-05-24_az847_rule9_wording_followup.md
for rule-9 wording update in module-layout.md.
Test results: 2287 passed, 90 skipped (environmental — Docker / CUDA
/ TensorRT / Jetson hardware / fixtures), 0 failed. Focused subset
(replay_input/ + c11_tile_manager/ + test_az270_compose_root.py)
also clean: 169 passed, 1 skipped.
Tracker: AZ-845/846/847 transitioned In Progress -> In Testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
702 lines
24 KiB
Python
702 lines
24 KiB
Python
"""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._types.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()
|