mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 11:21:13 +00:00
[AZ-838] SatelliteProviderRouteClient + seed_route.py CLI (E-AZ-835 C2)
ci/woodpecker/push/02-build-push Pipeline failed
ci/woodpecker/push/02-build-push Pipeline failed
Operator-side HTTP client + CLI that takes a RouteSpec from AZ-836 and onboards it via satellite-provider's POST /api/satellite/route: pre-emptive AZ-809 validation, request submission, polling until mapsReady, and POST /api/satellite/tiles/inventory verify. Lives in c11_tile_manager (shared parent-suite HTTP/JWT plumbing, shared BUILD_C11_TILE_MANAGER gate); error hierarchy split off SatelliteProviderRouteError to keep the tile path and route path independent. 30 unit tests + 1 RUN_E2E-gated integration test. Pre-emptive validator tracks the actual AZ-809 server bounds (points [2,500], zoom [0,22]) instead of the AZ-838 spec's narrower client-only bounds; flagged as F1 in batch_107_cycle3_report.md for user decision (accept-and-update-spec / revert-to-spec). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+404
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a tlog-derived route via satellite-provider's Route API (AZ-838).
|
||||
|
||||
Second deliverable of Epic AZ-835 (C2). Reads a Mavlink ``.tlog`` file,
|
||||
extracts a route via AZ-836's :func:`extract_route_from_tlog`, then
|
||||
hands the resulting :class:`RouteSpec` to
|
||||
:class:`SatelliteProviderRouteClient` which:
|
||||
|
||||
1. Pre-emptively validates the request body against AZ-809 rules.
|
||||
2. POSTs ``/api/satellite/route`` with ``requestMaps=true``.
|
||||
3. Polls ``GET /api/satellite/route/{id}`` until ``mapsReady=true`` or
|
||||
a terminal failure status.
|
||||
4. Verifies coverage via ``POST /api/satellite/tiles/inventory``.
|
||||
|
||||
This script is intended to run from the gps-denied-onboard repo root
|
||||
against a running ``satellite-provider`` (typically the Jetson e2e
|
||||
harness's service). It does NOT spin up the service itself and does
|
||||
NOT modify any satellite-provider code or configuration.
|
||||
|
||||
Required environment (loaded from ``.env.test`` if not exported)::
|
||||
|
||||
SATELLITE_PROVIDER_URL e.g. https://satellite-provider:8080
|
||||
SATELLITE_PROVIDER_API_KEY a valid HS256 JWT (mint with scripts/mint_dev_jwt.py)
|
||||
SATELLITE_PROVIDER_TLS_INSECURE optional, "1" to accept self-signed dev certs
|
||||
JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE required only if --auto-mint-jwt is passed
|
||||
|
||||
Usage::
|
||||
|
||||
# mint a JWT then seed using a Derkachi tlog
|
||||
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
|
||||
python tests/fixtures/derkachi_c6/seed_route.py \
|
||||
--tlog tests/fixtures/derkachi_c6/derkachi.tlog
|
||||
|
||||
# dry-run: extract route, print planned payload + sha256, no HTTP
|
||||
python tests/fixtures/derkachi_c6/seed_route.py \
|
||||
--tlog tests/fixtures/derkachi_c6/derkachi.tlog --dry-run
|
||||
|
||||
# write a JSON summary for downstream consumers (CI / fixture)
|
||||
python tests/fixtures/derkachi_c6/seed_route.py \
|
||||
--tlog tests/fixtures/derkachi_c6/derkachi.tlog \
|
||||
--output-summary /tmp/route_seed.json
|
||||
|
||||
Exit codes::
|
||||
|
||||
0 route reached mapsReady=true and inventory verification passed
|
||||
71 config malformed (tlog unreadable / no waypoints extracted)
|
||||
72 required env var missing
|
||||
73 satellite-provider unreachable (network / TLS error)
|
||||
74 route request rejected (HTTP 4xx + ProblemDetails)
|
||||
75 HTTP 5xx OR route terminal failure (mapsReady never reached)
|
||||
76 inventory verification mismatch (zero tiles present)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx # noqa: F401 -- imported eagerly so missing-dep exits 72
|
||||
except ImportError as exc:
|
||||
sys.stderr.write(
|
||||
f"ERROR: httpx not installed: {exc}\n"
|
||||
"Run `pip install -e .[dev]` from the repo root.\n"
|
||||
)
|
||||
sys.exit(72)
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
RouteTerminalFailureError,
|
||||
RouteTransientError,
|
||||
RouteValidationError,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||
SatelliteProviderRouteClient,
|
||||
)
|
||||
from gps_denied_onboard.replay_input.tlog_route import extract_route_from_tlog
|
||||
|
||||
|
||||
_DEFAULT_MAX_WAYPOINTS = 10
|
||||
_DEFAULT_REGION_SIZE_M = 500.0
|
||||
_DEFAULT_ZOOM_LEVEL = 18
|
||||
_DEFAULT_TLOG = Path("tests/fixtures/derkachi_c6/derkachi.tlog")
|
||||
_MIN_INVENTORY_PRESENT = 1
|
||||
|
||||
|
||||
def _load_env_file(path: Path) -> dict[str, str]:
|
||||
"""Parse a KEY=VALUE env file. Honours quoting; ignores comments."""
|
||||
|
||||
if not path.is_file():
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for raw in path.read_text("utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
out[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_env(name: str, env_file_values: dict[str, str]) -> str | None:
|
||||
return os.environ.get(name) or env_file_values.get(name)
|
||||
|
||||
|
||||
def _auto_mint_jwt() -> str | None:
|
||||
"""Run ``scripts/mint_dev_jwt.py`` and return the printed JWT.
|
||||
|
||||
Mirrors what an operator would do manually:
|
||||
``export SATELLITE_PROVIDER_API_KEY=$(python scripts/mint_dev_jwt.py)``.
|
||||
Returns ``None`` if the script fails or is missing — the caller
|
||||
surfaces that as exit 72 (missing env).
|
||||
"""
|
||||
|
||||
script = Path("scripts/mint_dev_jwt.py")
|
||||
if not script.is_file():
|
||||
sys.stderr.write(
|
||||
f"ERROR: --auto-mint-jwt requested but {script} not found.\n"
|
||||
)
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"ERROR: --auto-mint-jwt failed to launch: {exc}\n")
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(
|
||||
f"ERROR: --auto-mint-jwt exited with rc={result.returncode}; "
|
||||
f"stderr={result.stderr.strip()!r}\n"
|
||||
)
|
||||
return None
|
||||
token = result.stdout.strip()
|
||||
if not token:
|
||||
sys.stderr.write("ERROR: --auto-mint-jwt produced empty output.\n")
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--tlog",
|
||||
type=Path,
|
||||
default=_DEFAULT_TLOG,
|
||||
help=(
|
||||
f"Path to the .tlog file to extract a route from "
|
||||
f"(default: {_DEFAULT_TLOG})."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-waypoints",
|
||||
type=int,
|
||||
default=_DEFAULT_MAX_WAYPOINTS,
|
||||
help=(
|
||||
f"Maximum waypoints to keep after Douglas-Peucker decimation "
|
||||
f"(default: {_DEFAULT_MAX_WAYPOINTS}, see AZ-836)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--region-size-meters",
|
||||
type=float,
|
||||
default=_DEFAULT_REGION_SIZE_M,
|
||||
help=(
|
||||
f"Per-waypoint region size in metres "
|
||||
f"(default: {_DEFAULT_REGION_SIZE_M}; AZ-809 range "
|
||||
f"[100, 10000])."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zoom-level",
|
||||
type=int,
|
||||
default=_DEFAULT_ZOOM_LEVEL,
|
||||
help=(
|
||||
f"Web-Mercator zoom for the route "
|
||||
f"(default: {_DEFAULT_ZOOM_LEVEL}; AZ-809 range [0, 22])."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Optional human-readable name. Default: "
|
||||
"<tlog-stem>-<short-hash> (deterministic per RouteSpec)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional free-text description (max 1000 chars per AZ-809).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env-file",
|
||||
type=Path,
|
||||
default=Path(".env.test"),
|
||||
help="Fallback env file (default: .env.test in CWD).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-summary",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Optional path to write a JSON summary of the seeding run.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Extract route and print planned payload + sha256 without "
|
||||
"submitting any HTTP request."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto-mint-jwt",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Run scripts/mint_dev_jwt.py to mint a fresh JWT instead of "
|
||||
"reading SATELLITE_PROVIDER_API_KEY from env."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.tlog.is_file():
|
||||
sys.stderr.write(f"ERROR: tlog not found: {args.tlog}\n")
|
||||
return 71
|
||||
|
||||
try:
|
||||
spec = extract_route_from_tlog(
|
||||
args.tlog,
|
||||
max_waypoints=args.max_waypoints,
|
||||
)
|
||||
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
||||
sys.stderr.write(
|
||||
f"ERROR: failed to extract route from {args.tlog}: {exc}\n"
|
||||
)
|
||||
return 71
|
||||
|
||||
print(
|
||||
f"[plan] tlog: {args.tlog}\n"
|
||||
f"[plan] waypoints (after decimation): {len(spec.waypoints)}\n"
|
||||
f"[plan] region_size_meters: {args.region_size_meters}\n"
|
||||
f"[plan] zoom_level: {args.zoom_level}\n"
|
||||
f"[plan] suggested_region_size_meters (from RouteSpec): "
|
||||
f"{spec.suggested_region_size_meters}"
|
||||
)
|
||||
|
||||
env_file_values = _load_env_file(args.env_file)
|
||||
sp_url = _resolve_env("SATELLITE_PROVIDER_URL", env_file_values)
|
||||
tls_insecure = (
|
||||
_resolve_env("SATELLITE_PROVIDER_TLS_INSECURE", env_file_values) == "1"
|
||||
)
|
||||
|
||||
if args.auto_mint_jwt:
|
||||
jwt_token = _auto_mint_jwt()
|
||||
else:
|
||||
jwt_token = _resolve_env("SATELLITE_PROVIDER_API_KEY", env_file_values)
|
||||
|
||||
if args.dry_run:
|
||||
# Build the planned body with a placeholder URL/JWT — the
|
||||
# client never makes an HTTP call in dry-run mode but does
|
||||
# run pre-emptive validation, so an OOR field still surfaces.
|
||||
client = SatelliteProviderRouteClient(
|
||||
base_url=sp_url or "https://placeholder.invalid",
|
||||
jwt=jwt_token or "placeholder",
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
try:
|
||||
body, sha256 = client.build_planned_payload(
|
||||
spec,
|
||||
name=args.name,
|
||||
region_size_meters=args.region_size_meters,
|
||||
zoom_level=args.zoom_level,
|
||||
description=args.description,
|
||||
)
|
||||
except RouteValidationError as exc:
|
||||
sys.stderr.write(
|
||||
f"ERROR: pre-emptive validation rejected request: {exc}\n"
|
||||
f" field_errors={json.dumps(exc.field_errors, indent=2)}\n"
|
||||
)
|
||||
return 74
|
||||
print("\n[dry-run] planned payload:")
|
||||
print(json.dumps(body, indent=2, sort_keys=True))
|
||||
print(f"\n[dry-run] payload sha256: {sha256}")
|
||||
return 0
|
||||
|
||||
if not sp_url:
|
||||
sys.stderr.write("ERROR: SATELLITE_PROVIDER_URL not set (env or .env.test).\n")
|
||||
return 72
|
||||
if not jwt_token:
|
||||
sys.stderr.write(
|
||||
"ERROR: SATELLITE_PROVIDER_API_KEY not set. Mint with:\n"
|
||||
" python scripts/mint_dev_jwt.py\n"
|
||||
"or pass --auto-mint-jwt.\n"
|
||||
)
|
||||
return 72
|
||||
|
||||
client = SatelliteProviderRouteClient(
|
||||
base_url=sp_url,
|
||||
jwt=jwt_token,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
|
||||
print(
|
||||
f"\n[submit] satellite-provider: {sp_url} "
|
||||
f"(tls_insecure={tls_insecure})"
|
||||
)
|
||||
try:
|
||||
result = client.seed_route(
|
||||
spec,
|
||||
name=args.name,
|
||||
region_size_meters=args.region_size_meters,
|
||||
zoom_level=args.zoom_level,
|
||||
description=args.description,
|
||||
)
|
||||
except RouteValidationError as exc:
|
||||
sys.stderr.write(
|
||||
f"ERROR: route POST rejected (4xx): {exc}\n"
|
||||
f" http_status={exc.http_status}\n"
|
||||
f" field_errors={json.dumps(exc.field_errors, indent=2)}\n"
|
||||
)
|
||||
return 74
|
||||
except RouteTransientError as exc:
|
||||
cause = exc.__cause__
|
||||
sys.stderr.write(
|
||||
f"ERROR: satellite-provider transient failure: {exc}\n"
|
||||
)
|
||||
if cause is not None:
|
||||
sys.stderr.write(f" cause: {type(cause).__name__}: {cause}\n")
|
||||
# Distinguish unreachable (no cause / connection-level) from
|
||||
# 5xx so operators can read the exit code without parsing logs.
|
||||
if cause is not None and isinstance(
|
||||
cause, (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)
|
||||
):
|
||||
return 73
|
||||
return 75
|
||||
except RouteTerminalFailureError as exc:
|
||||
sys.stderr.write(
|
||||
f"ERROR: route did not complete: {exc}\n"
|
||||
f" route_id={exc.route_id}\n"
|
||||
f" detail={json.dumps(exc.detail, indent=2) if exc.detail else None}\n"
|
||||
)
|
||||
return 75
|
||||
|
||||
print(
|
||||
f"\n[done] route_id={result.route_id}\n"
|
||||
f"[done] terminal_status={result.terminal_status}\n"
|
||||
f"[done] maps_ready={result.maps_ready}\n"
|
||||
f"[done] tile_count={result.tile_count}\n"
|
||||
f"[done] elapsed_ms={result.elapsed_ms}\n"
|
||||
f"[done] payload_sha256={result.submitted_payload_sha256}"
|
||||
)
|
||||
|
||||
if result.tile_count < _MIN_INVENTORY_PRESENT:
|
||||
sys.stderr.write(
|
||||
"ERROR: inventory verification reported zero tiles present "
|
||||
"for the route's coverage. The server reached mapsReady=true "
|
||||
"but the inventory call returned no matches — this typically "
|
||||
"indicates a coverage / projection mismatch and warrants "
|
||||
"investigation.\n"
|
||||
)
|
||||
return 76
|
||||
|
||||
if args.output_summary:
|
||||
summary = {
|
||||
"tlog": str(args.tlog),
|
||||
"sp_url": sp_url,
|
||||
"tls_insecure": tls_insecure,
|
||||
"spec": {
|
||||
"waypoint_count": len(spec.waypoints),
|
||||
"suggested_region_size_meters": spec.suggested_region_size_meters,
|
||||
"source_tlog": str(spec.source_tlog) if spec.source_tlog else None,
|
||||
},
|
||||
"request": {
|
||||
"name": args.name,
|
||||
"region_size_meters": args.region_size_meters,
|
||||
"zoom_level": args.zoom_level,
|
||||
"description": args.description,
|
||||
},
|
||||
"result": {
|
||||
"route_id": str(result.route_id),
|
||||
"terminal_status": result.terminal_status,
|
||||
"maps_ready": result.maps_ready,
|
||||
"tile_count": result.tile_count,
|
||||
"elapsed_ms": result.elapsed_ms,
|
||||
"submitted_payload_sha256": result.submitted_payload_sha256,
|
||||
},
|
||||
}
|
||||
args.output_summary.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_summary.write_text(json.dumps(summary, indent=2))
|
||||
print(f"[done] summary written to {args.output_summary}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
"""AZ-838 Route client integration test (AC-8, AC-10).
|
||||
|
||||
Gated on ``RUN_E2E=1`` AND ``SATELLITE_PROVIDER_URL`` AND
|
||||
``SATELLITE_PROVIDER_API_KEY`` AND ``DERKACHI_TLOG`` per the AZ-838
|
||||
spec. Without those, the test SKIPs with an explicit reason — same
|
||||
pattern as AZ-404's ``RUN_REPLAY_E2E`` gate. The intent is that AC-8
|
||||
and AC-10 have a concrete pytest entry point so they can be exercised
|
||||
on the Jetson harness without re-discovering wiring.
|
||||
|
||||
The test is intentionally minimal: it verifies that when a real
|
||||
``satellite-provider`` is reachable, a Derkachi tlog round-trips
|
||||
through :class:`SatelliteProviderRouteClient.seed_route` and reports
|
||||
``maps_ready=True`` with a non-zero ``tile_count``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||
SatelliteProviderRouteClient,
|
||||
)
|
||||
from gps_denied_onboard.replay_input.tlog_route import extract_route_from_tlog
|
||||
|
||||
|
||||
_RUN_E2E = os.getenv("RUN_E2E") == "1"
|
||||
_SP_URL = os.getenv("SATELLITE_PROVIDER_URL")
|
||||
_SP_JWT = os.getenv("SATELLITE_PROVIDER_API_KEY")
|
||||
_TLS_INSECURE = os.getenv("SATELLITE_PROVIDER_TLS_INSECURE") == "1"
|
||||
_DERKACHI_TLOG = os.getenv("DERKACHI_TLOG")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (_RUN_E2E and _SP_URL and _SP_JWT and _DERKACHI_TLOG),
|
||||
reason=(
|
||||
"AZ-838 AC-8/AC-10 require RUN_E2E=1 + SATELLITE_PROVIDER_URL + "
|
||||
"SATELLITE_PROVIDER_API_KEY + DERKACHI_TLOG (path to derkachi.tlog) "
|
||||
"— typically run on the Jetson e2e harness."
|
||||
),
|
||||
)
|
||||
def test_seed_route_against_live_sp_with_derkachi_tlog() -> None:
|
||||
# Arrange
|
||||
tlog_path = Path(_DERKACHI_TLOG) # type: ignore[arg-type]
|
||||
assert tlog_path.is_file(), f"derkachi tlog not found: {tlog_path}"
|
||||
spec = extract_route_from_tlog(tlog_path, max_waypoints=10)
|
||||
client = SatelliteProviderRouteClient(
|
||||
base_url=_SP_URL, # type: ignore[arg-type]
|
||||
jwt=_SP_JWT, # type: ignore[arg-type]
|
||||
tls_insecure=_TLS_INSECURE,
|
||||
poll_interval_s=5.0,
|
||||
poll_max_attempts=24,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = client.seed_route(
|
||||
spec,
|
||||
name=f"az838-derkachi-{tlog_path.stem}",
|
||||
region_size_meters=500.0,
|
||||
zoom_level=18,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.maps_ready is True
|
||||
assert result.terminal_status == "completed"
|
||||
assert result.tile_count > 0
|
||||
@@ -0,0 +1,701 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user