mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:31:13 +00:00
[AZ-489] C12 FlightsApiClient + offline JSON loader + bbox helper
ADR-010 primary cold-start path now has a real source for the cache bbox and the takeoff origin. Single concrete strategy (`HttpxFlightsApiClient`) behind a `@runtime_checkable` Protocol; offline JSON fallback (`load_flight_file`) shares the same DTO shape per FAC-INV-1. * `flights_api/interface.py` — `FlightsApiClient` Protocol + `FlightDto` + `WaypointDto` + `WaypointObjective` / `WaypointSource` enums (plain frozen-slotted dataclasses, matching project's LatLonAlt / PoseEstimate pattern). * `flights_api/errors.py` — 8-class hierarchy under `FlightsApiError`. * `flights_api/_parser.py` — shared JSON validator: range checks, lat/lon bounds, contiguous ordinals, finite floats, enum membership. * `flights_api/bbox.py` — `bbox_from_waypoints` envelopes lat/lon and inflates by a horizontal-distance buffer via WgsConverter ENU round-trip (NOT degree-space); `takeoff_origin_from_flight` passes waypoints[0] through unrounded. * `flights_api/file_loader.py` — orjson-backed offline loader. * `flights_api/httpx_client.py` — concrete client with ONE retry on transient 5xx + connect errors; token redaction at every log site; test-injectable transport + sleep. * `runtime_root/c12_factory.py` — `build_flights_api_client(config)`; re-exported from `runtime_root/__init__.py`. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. * `pyproject.toml` — `httpx>=0.28,<1.0` added (chosen over `requests` for native `MockTransport` testing). Tests: 28 cases across AC-1..AC-18 plus extras (malformed JSON, negative buffer, zero buffer, missing top-level fields, negative ordinal, empty-flight takeoff). Full repo run: 713 passed, 2 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
"""AZ-489 — C12 ``FlightsApiClient`` unit tests.
|
||||
|
||||
Covers AC-1..AC-18 from ``_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md``.
|
||||
|
||||
Online tests use ``httpx.MockTransport`` (httpx's native mock; no extra
|
||||
HTTP-mocking dependency). Offline tests use ``tmp_path``-backed JSON
|
||||
files. Bbox tests validate the FAC-INV-3 horizontal-distance buffer at
|
||||
50 deg N against the canonical metres-per-degree expectations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||
EmptyWaypointsError,
|
||||
FlightDto,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
FlightsApiAuthError,
|
||||
FlightsApiClient,
|
||||
FlightsApiSchemaError,
|
||||
FlightsApiUnreachableError,
|
||||
HttpxFlightsApiClient,
|
||||
WaypointDto,
|
||||
WaypointObjective,
|
||||
WaypointSchemaError,
|
||||
WaypointSource,
|
||||
bbox_from_waypoints,
|
||||
load_flight_file,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.c12_factory import build_flights_api_client
|
||||
|
||||
FLIGHT_ID = UUID("11111111-2222-3333-4444-555555555555")
|
||||
BASE_URL = "https://flights.example/api"
|
||||
AUTH_TOKEN = "bearer-secret-abc" # fake token used only in tests
|
||||
|
||||
|
||||
def _waypoint_payload(
|
||||
*,
|
||||
ordinal: int = 0,
|
||||
lat_deg: float = 50.0,
|
||||
lon_deg: float = 36.2,
|
||||
alt_m: float = 200.0,
|
||||
objective: str = "waypoint",
|
||||
source: str = "operator",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"ordinal": ordinal,
|
||||
"lat_deg": lat_deg,
|
||||
"lon_deg": lon_deg,
|
||||
"alt_m": alt_m,
|
||||
"objective": objective,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def _three_waypoint_payload(*, flight_id: UUID = FLIGHT_ID) -> dict[str, object]:
|
||||
return {
|
||||
"flight_id": str(flight_id),
|
||||
"name": "derkachi-sweep",
|
||||
"waypoints": [
|
||||
_waypoint_payload(ordinal=0, lat_deg=50.0, lon_deg=36.2, alt_m=200.0,
|
||||
objective="takeoff"),
|
||||
_waypoint_payload(ordinal=1, lat_deg=50.01, lon_deg=36.22, alt_m=210.0),
|
||||
_waypoint_payload(ordinal=2, lat_deg=50.02, lon_deg=36.24, alt_m=220.0,
|
||||
objective="landing"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _make_client_with_handler(
|
||||
handler: Callable[[httpx.Request], httpx.Response],
|
||||
) -> tuple[HttpxFlightsApiClient, list[float]]:
|
||||
sleeps: list[float] = []
|
||||
|
||||
def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = HttpxFlightsApiClient(transport=transport, sleep=fake_sleep)
|
||||
return client, sleeps
|
||||
|
||||
|
||||
def _attach_capturing_handler() -> tuple[logging.Handler, io.StringIO]:
|
||||
buffer = io.StringIO()
|
||||
handler = logging.StreamHandler(buffer)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
return handler, buffer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_flights_api_logs() -> tuple[logging.Handler, io.StringIO]:
|
||||
handler, buffer = _attach_capturing_handler()
|
||||
logger = logging.getLogger("c12.flights_api")
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
yield handler, buffer
|
||||
logger.removeHandler(handler)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-1: Online happy path
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_online_happy_path_returns_three_waypoint_flight(
|
||||
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(200, json=_three_waypoint_payload())
|
||||
|
||||
client, sleeps = _make_client_with_handler(handler)
|
||||
_, buffer = capture_flights_api_logs
|
||||
|
||||
# Act
|
||||
flight = client.fetch_flight(
|
||||
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(flight, FlightDto)
|
||||
assert flight.flight_id == FLIGHT_ID
|
||||
assert len(flight.waypoints) == 3
|
||||
assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
|
||||
assert call_count == 1
|
||||
assert sleeps == []
|
||||
log_output = buffer.getvalue()
|
||||
assert AUTH_TOKEN not in log_output
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-2: 404 - FlightNotFoundError, no retry
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_online_404_raises_flight_not_found_without_retry() -> None:
|
||||
# Arrange
|
||||
call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(404, json={"error": "not found"})
|
||||
|
||||
client, sleeps = _make_client_with_handler(handler)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightNotFoundError) as exc_info:
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
|
||||
assert str(FLIGHT_ID) in str(exc_info.value)
|
||||
assert call_count == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-3: 401 - FlightsApiAuthError, no retry, no token in logs
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_online_401_raises_auth_error_without_logging_token(
|
||||
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(401, json={"error": "unauthorized"})
|
||||
|
||||
client, sleeps = _make_client_with_handler(handler)
|
||||
_, buffer = capture_flights_api_logs
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightsApiAuthError) as exc_info:
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
|
||||
assert AUTH_TOKEN not in str(exc_info.value)
|
||||
assert call_count == 1
|
||||
assert sleeps == []
|
||||
assert AUTH_TOKEN not in buffer.getvalue()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-4: 503 transient -> retry once -> success
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac4_online_503_then_200_retries_once_and_succeeds(
|
||||
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return httpx.Response(503, json={"error": "transient"})
|
||||
return httpx.Response(200, json=_three_waypoint_payload())
|
||||
|
||||
client, sleeps = _make_client_with_handler(handler)
|
||||
_, buffer = capture_flights_api_logs
|
||||
|
||||
# Act
|
||||
flight = client.fetch_flight(
|
||||
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(flight, FlightDto)
|
||||
assert call_count == 2
|
||||
assert sleeps == [1.0]
|
||||
log_output = buffer.getvalue()
|
||||
assert "c12.flights.fetch.retry" in log_output
|
||||
assert AUTH_TOKEN not in log_output
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-5: 503 persistent -> Unreachable after one retry
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac5_online_503_always_raises_unreachable_after_one_retry(
|
||||
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(503, json={"error": "down"})
|
||||
|
||||
client, sleeps = _make_client_with_handler(handler)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightsApiUnreachableError):
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
|
||||
assert call_count == 2
|
||||
assert sleeps == [1.0]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-6: Schema drift (missing lat) raises FlightsApiSchemaError or WaypointSchemaError
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac6_online_schema_drift_raises_with_field_reference() -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
waypoints = payload["waypoints"]
|
||||
assert isinstance(waypoints, list)
|
||||
del waypoints[1]["lat_deg"] # type: ignore[index]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
client, _ = _make_client_with_handler(handler)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(WaypointSchemaError) as exc_info:
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
|
||||
assert "lat_deg" in str(exc_info.value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-7: Offline happy path
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac7_offline_happy_path_returns_equivalent_flight_dto(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
flight_file = tmp_path / "flight.json"
|
||||
flight_file.write_bytes(json.dumps(payload).encode())
|
||||
|
||||
# Act
|
||||
flight = load_flight_file(path=flight_file)
|
||||
|
||||
# Assert
|
||||
assert flight.flight_id == FLIGHT_ID
|
||||
assert len(flight.waypoints) == 3
|
||||
assert flight.waypoints[0].objective == WaypointObjective.TAKEOFF
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-8: Offline missing file
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac8_offline_missing_file_raises_with_path_in_message(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
missing = tmp_path / "does-not-exist.json"
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightFileNotFoundError) as exc_info:
|
||||
load_flight_file(path=missing)
|
||||
|
||||
assert str(missing) in str(exc_info.value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-9: Empty waypoints -> bbox raises EmptyWaypointsError
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac9_empty_waypoints_into_bbox_raises_empty_waypoints_error() -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(EmptyWaypointsError):
|
||||
bbox_from_waypoints((), buffer_m=1000.0)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-10: Bbox 1 km buffer at 50N stays within 5% of horizontal-distance target
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_corner_waypoints(centre: LatLonAlt, half_extent_m: float) -> tuple[WaypointDto, ...]:
|
||||
metres_per_deg_lat = 111_320.0
|
||||
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
|
||||
d_lat = half_extent_m / metres_per_deg_lat
|
||||
d_lon = half_extent_m / metres_per_deg_lon
|
||||
return (
|
||||
WaypointDto(
|
||||
ordinal=0,
|
||||
lat_deg=centre.lat_deg - d_lat,
|
||||
lon_deg=centre.lon_deg - d_lon,
|
||||
alt_m=centre.alt_m,
|
||||
objective=WaypointObjective.TAKEOFF,
|
||||
source=WaypointSource.OPERATOR,
|
||||
),
|
||||
WaypointDto(
|
||||
ordinal=1,
|
||||
lat_deg=centre.lat_deg - d_lat,
|
||||
lon_deg=centre.lon_deg + d_lon,
|
||||
alt_m=centre.alt_m,
|
||||
objective=WaypointObjective.WAYPOINT,
|
||||
source=WaypointSource.OPERATOR,
|
||||
),
|
||||
WaypointDto(
|
||||
ordinal=2,
|
||||
lat_deg=centre.lat_deg + d_lat,
|
||||
lon_deg=centre.lon_deg + d_lon,
|
||||
alt_m=centre.alt_m,
|
||||
objective=WaypointObjective.WAYPOINT,
|
||||
source=WaypointSource.OPERATOR,
|
||||
),
|
||||
WaypointDto(
|
||||
ordinal=3,
|
||||
lat_deg=centre.lat_deg + d_lat,
|
||||
lon_deg=centre.lon_deg - d_lon,
|
||||
alt_m=centre.alt_m,
|
||||
objective=WaypointObjective.LANDING,
|
||||
source=WaypointSource.OPERATOR,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ac10_bbox_buffer_is_horizontal_distance_within_five_percent_at_50n() -> None:
|
||||
# Arrange
|
||||
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||
half_extent_m = 500.0 # 1 km box overall
|
||||
waypoints = _make_corner_waypoints(centre, half_extent_m)
|
||||
buffer_m = 1000.0
|
||||
|
||||
# Act
|
||||
bbox = bbox_from_waypoints(waypoints, buffer_m=buffer_m)
|
||||
|
||||
# Assert
|
||||
metres_per_deg_lat = 111_320.0
|
||||
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
|
||||
expected_lat_half_extent = half_extent_m + buffer_m
|
||||
expected_lon_half_extent = half_extent_m + buffer_m
|
||||
expected_min_lat = centre.lat_deg - expected_lat_half_extent / metres_per_deg_lat
|
||||
expected_max_lat = centre.lat_deg + expected_lat_half_extent / metres_per_deg_lat
|
||||
expected_min_lon = centre.lon_deg - expected_lon_half_extent / metres_per_deg_lon
|
||||
expected_max_lon = centre.lon_deg + expected_lon_half_extent / metres_per_deg_lon
|
||||
|
||||
assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.05)
|
||||
assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.05)
|
||||
assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.05)
|
||||
assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.05)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-11: Takeoff origin pass-through
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac11_takeoff_origin_is_first_waypoint_with_no_rounding() -> None:
|
||||
# Arrange
|
||||
flight = FlightDto(
|
||||
flight_id=FLIGHT_ID,
|
||||
name="derkachi",
|
||||
waypoints=(
|
||||
WaypointDto(
|
||||
ordinal=0,
|
||||
lat_deg=50.000000001,
|
||||
lon_deg=36.200000001,
|
||||
alt_m=200.000000001,
|
||||
objective=WaypointObjective.TAKEOFF,
|
||||
source=WaypointSource.OPERATOR,
|
||||
),
|
||||
WaypointDto(
|
||||
ordinal=1,
|
||||
lat_deg=51.0,
|
||||
lon_deg=37.0,
|
||||
alt_m=210.0,
|
||||
objective=WaypointObjective.WAYPOINT,
|
||||
source=WaypointSource.OPERATOR,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Act
|
||||
origin = takeoff_origin_from_flight(flight)
|
||||
|
||||
# Assert
|
||||
assert origin == LatLonAlt(50.000000001, 36.200000001, 200.000000001)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-12: Conformance
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac12_httpx_flights_api_client_satisfies_protocol() -> None:
|
||||
# Assert
|
||||
assert isinstance(HttpxFlightsApiClient(), FlightsApiClient)
|
||||
|
||||
|
||||
def test_ac12_runtime_root_factory_returns_protocol_conforming_instance() -> None:
|
||||
# Arrange
|
||||
config = object() # factory ignores config in v1
|
||||
|
||||
# Act
|
||||
client = build_flights_api_client(config) # type: ignore[arg-type]
|
||||
|
||||
# Assert
|
||||
assert isinstance(client, FlightsApiClient)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-13: Online + Offline equality
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac13_online_and_offline_produce_equal_dtos(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
client, _ = _make_client_with_handler(handler)
|
||||
flight_file = tmp_path / "flight.json"
|
||||
flight_file.write_bytes(json.dumps(payload).encode())
|
||||
|
||||
# Act
|
||||
online_dto = client.fetch_flight(
|
||||
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||
)
|
||||
offline_dto = load_flight_file(path=flight_file)
|
||||
|
||||
# Assert
|
||||
assert online_dto == offline_dto
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-14: Shuffled ordinals -> sorted output
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac14_shuffled_ordinals_are_returned_in_sorted_order() -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
waypoints = payload["waypoints"]
|
||||
assert isinstance(waypoints, list)
|
||||
waypoints[0], waypoints[2] = waypoints[2], waypoints[0]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
client, _ = _make_client_with_handler(handler)
|
||||
|
||||
# Act
|
||||
flight = client.fetch_flight(
|
||||
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-15: Ordinal gap raises WaypointSchemaError
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac15_ordinal_gap_raises_waypoint_schema_error() -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
waypoints = payload["waypoints"]
|
||||
assert isinstance(waypoints, list)
|
||||
waypoints[2]["ordinal"] = 5 # type: ignore[index]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
client, _ = _make_client_with_handler(handler)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(WaypointSchemaError) as exc_info:
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
assert "ordinal" in str(exc_info.value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-16: Out-of-range lat raises WaypointSchemaError
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac16_lat_200_raises_waypoint_schema_error() -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
waypoints = payload["waypoints"]
|
||||
assert isinstance(waypoints, list)
|
||||
waypoints[0]["lat_deg"] = 200.0 # type: ignore[index]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
client, _ = _make_client_with_handler(handler)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(WaypointSchemaError) as exc_info:
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
assert "lat_deg" in str(exc_info.value)
|
||||
assert "200" in str(exc_info.value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-17: Token redaction across all paths
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code,first_payload,second_payload",
|
||||
[
|
||||
(200, _three_waypoint_payload(), None),
|
||||
(401, {"error": "unauthorized"}, None),
|
||||
(404, {"error": "not found"}, None),
|
||||
(500, {"error": "server"}, None),
|
||||
],
|
||||
)
|
||||
def test_ac17_auth_token_never_appears_in_logs(
|
||||
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||
status_code: int,
|
||||
first_payload: dict[str, object],
|
||||
second_payload: dict[str, object] | None,
|
||||
) -> None:
|
||||
# Arrange
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code, json=first_payload)
|
||||
|
||||
client, _ = _make_client_with_handler(handler)
|
||||
_, buffer = capture_flights_api_logs
|
||||
|
||||
# Act
|
||||
try:
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Assert
|
||||
log_output = buffer.getvalue()
|
||||
assert AUTH_TOKEN not in log_output
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AC-18: Timeout (connect error) -> Unreachable after one retry
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac18_persistent_connect_error_raises_unreachable_after_one_retry(
|
||||
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise httpx.ConnectError("simulated tcp reset")
|
||||
|
||||
client, sleeps = _make_client_with_handler(handler)
|
||||
_, buffer = capture_flights_api_logs
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightsApiUnreachableError):
|
||||
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||
|
||||
assert call_count == 2
|
||||
assert sleeps == [1.0]
|
||||
assert "c12.flights.fetch.retry" in buffer.getvalue()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Extra coverage: file with malformed JSON, bbox negative buffer
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_offline_malformed_json_raises_schema_error(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
flight_file = tmp_path / "broken.json"
|
||||
flight_file.write_bytes(b"{not-json")
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightsApiSchemaError):
|
||||
load_flight_file(path=flight_file)
|
||||
|
||||
|
||||
def test_bbox_negative_buffer_raises_value_error() -> None:
|
||||
# Arrange
|
||||
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||
waypoints = _make_corner_waypoints(centre, 500.0)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError):
|
||||
bbox_from_waypoints(waypoints, buffer_m=-1.0)
|
||||
|
||||
|
||||
def test_bbox_zero_buffer_returns_envelope() -> None:
|
||||
# Arrange
|
||||
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||
waypoints = _make_corner_waypoints(centre, 500.0)
|
||||
|
||||
# Act
|
||||
bbox = bbox_from_waypoints(waypoints, buffer_m=0.0)
|
||||
|
||||
# Assert
|
||||
metres_per_deg_lat = 111_320.0
|
||||
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
|
||||
expected_min_lat = centre.lat_deg - 500.0 / metres_per_deg_lat
|
||||
expected_max_lat = centre.lat_deg + 500.0 / metres_per_deg_lat
|
||||
expected_min_lon = centre.lon_deg - 500.0 / metres_per_deg_lon
|
||||
expected_max_lon = centre.lon_deg + 500.0 / metres_per_deg_lon
|
||||
assert isinstance(bbox, BoundingBox)
|
||||
assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.01)
|
||||
assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.01)
|
||||
assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.01)
|
||||
assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.01)
|
||||
|
||||
|
||||
def test_parser_rejects_missing_top_level_fields(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
flight_file = tmp_path / "no-flight-id.json"
|
||||
payload = _three_waypoint_payload()
|
||||
del payload["flight_id"]
|
||||
flight_file.write_bytes(json.dumps(payload).encode())
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightsApiSchemaError):
|
||||
load_flight_file(path=flight_file)
|
||||
|
||||
|
||||
def test_parser_rejects_negative_ordinal(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = _three_waypoint_payload()
|
||||
waypoints = payload["waypoints"]
|
||||
assert isinstance(waypoints, list)
|
||||
waypoints[0]["ordinal"] = -1 # type: ignore[index]
|
||||
flight_file = tmp_path / "neg.json"
|
||||
flight_file.write_bytes(json.dumps(payload).encode())
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(WaypointSchemaError):
|
||||
load_flight_file(path=flight_file)
|
||||
|
||||
|
||||
def test_takeoff_origin_on_empty_flight_raises_empty_waypoints_error() -> None:
|
||||
# Arrange
|
||||
flight = FlightDto(flight_id=FLIGHT_ID, name="empty", waypoints=())
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(EmptyWaypointsError):
|
||||
takeoff_origin_from_flight(flight)
|
||||
Reference in New Issue
Block a user