[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:
Oleksandr Bezdieniezhnykh
2026-05-12 01:28:49 +03:00
parent e0be591b06
commit 72a06edab0
15 changed files with 2057 additions and 2 deletions
@@ -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)