diff --git a/_docs/02_tasks/done/AZ-489_c12_flights_api_client.md b/_docs/02_tasks/done/AZ-489_c12_flights_api_client.md new file mode 100644 index 0000000..d2e32e5 --- /dev/null +++ b/_docs/02_tasks/done/AZ-489_c12_flights_api_client.md @@ -0,0 +1,238 @@ +# C12 FlightsApiClient — Fetch Flight from suite flights service + offline JSON fallback + +**Task**: AZ-489_c12_flights_api_client +**Name**: C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback +**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-tool build-cache --flight-id `; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file ` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0). +**Complexity**: 3 points +**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-279_wgs_converter (for the bbox buffer math) +**Component**: c12_operator_tooling (epic AZ-253 / E-C12) +**Tracker**: AZ-489 +**Epic**: AZ-253 (E-C12) + +### Document Dependencies + +- `_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases). +- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies). +- `_docs/02_document/architecture.md` — ADR-010 (operator-planned mission as cold-start trust anchor). +- Parent-suite reference (read-only): `suite/flights/Database/Entities/Flight.cs`, `suite/flights/Database/Entities/Waypoint.cs`, `suite/flights/Controllers/FlightsController.cs`. + +## Problem + +Without `FlightsApiClient`: + +- ADR-010's primary cold-start path (operator-planned mission → C10 Manifest `takeoff_origin` → C5 warm-start) cannot be wired. C10 has nowhere to get the origin from; C5 has nothing to seed. +- F1 phase 0 (Flight resolve) defined in `_docs/02_document/system-flows.md` cannot run; AZ-328's flight-resolve phase has no service to invoke. +- C12-CLI's `--flight-id` / `--flight-file` flags (AZ-326) have nothing to delegate to. +- The bbox in `BuildCacheRequest` would need to keep coming from operator-typed CLI args, drifting from the canonical mission authored in the Mission Planner UI — exactly the operator-error vector ADR-010 was created to remove. +- The offline path (`--flight-file`) has no implementation, so operator workstations without a path to the flights service can't run F1. +- The bbox buffer is currently undefined; without a contract there's no single source of truth for "how big around the route do we cache tiles". + +This task delivers the client + its frozen contract. It does NOT modify the CLI flag parsing (AZ-326), the orchestrator phase ordering (AZ-328), the Manifest schema (AZ-323), or any C5 path. + +## Outcome + +- A `FlightsApiClient` Protocol + concrete `HttpxFlightsApiClient` implementation at `src/operator_tool/flights_api_client.py`: + - Constructor: `__init__(self, *, httpx_client: httpx.Client | None = None, wgs_converter: WgsConverter, logger: Logger, clock: Clock)`. + - Public methods per the contract: `fetch_flight`, `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight`. +- DTOs at `src/operator_tool/flights_dto.py`: + - `FlightDto` + `WaypointDto` as `@dataclass(frozen=True, slots=True)` with pydantic validators (via `pydantic.dataclasses.dataclass` or a standalone `pydantic.TypeAdapter`). + - `WaypointObjective` + `WaypointSource` enums mirroring the C# enums. +- Offline loader at `src/operator_tool/flights_file_loader.py`: + - `load_flight_file(path: Path) -> FlightDto` — reads JSON via `orjson`, validates against the same pydantic schema, raises `FlightFileNotFoundError` / `FlightsApiSchemaError` / `WaypointSchemaError` per the contract. +- Bbox helper at `src/operator_tool/bbox_from_waypoints.py`: + - `bbox_from_waypoints(waypoints, *, buffer_m, wgs_converter) -> BoundingBox` — envelopes the lat/lon, then inflates by `buffer_m` horizontal distance (NOT degree-space) using `wgs_converter.metres_to_degrees(lat, buffer_m)`. +- Error hierarchy at `src/operator_tool/flights_api_errors.py`: + - `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`. +- Composition-root factory entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`: + - Extend the `OperatorToolServices` dataclass with `flights_api_client: FlightsApiClient`. + - `build_flights_api_client(config) -> FlightsApiClient` constructs the httpx client with TLS verify on (no `verify=False`), default timeout `10.0 s`, and the project's `WgsConverter`. +- Logging: + - INFO on every successful fetch (`kind="c12.flights.fetch.success"`) with `flight_id`, `waypoint_count`, `bbox` summary. NO `auth_token` in any log line. + - WARN on retry attempts (`kind="c12.flights.fetch.retry"`). + - ERROR on each failure variant (`kind="c12.flights.fetch.failed"` with the resolved error class name). + +## Scope + +### Included + +- `FlightsApiClient` Protocol + `HttpxFlightsApiClient` concrete impl. +- `FlightDto` + `WaypointDto` + enums. +- Online path (`fetch_flight`) with one-retry-on-transient-5xx-or-connect-error semantics per FAC-INV-5. +- Offline path (`load_flight_file`) reading the same DTO shape. +- `bbox_from_waypoints` envelope + horizontal-distance buffer via `WgsConverter`. +- `takeoff_origin_from_flight` pass-through of `waypoints[0]`. +- Error hierarchy with one-line operator-friendly text per class. +- Composition-root factory + service-dataclass extension. +- Unit tests covering every AC + Protocol conformance. + +### Excluded + +- Flight authoring / editing (Mission Planner UI owns). +- Live updates / websockets. +- Caching `FlightDto` across runs. +- Writing build status back to the `flights` REST service. +- Wiring `--flight-id` / `--flight-file` flags into the CLI (AZ-326). +- Wiring the resolved DTO into the orchestrator phase 0 (AZ-328). +- Anything that runs on the airborne companion (this is operator-workstation-only). + +## Acceptance Criteria + +**AC-1: Online happy path — `fetch_flight` returns a populated `FlightDto`** +Given a fake httpx transport that returns 200 OK with a valid 3-waypoint JSON body +When `fetch_flight(flight_id=..., base_url="https://flights.example", auth_token="abc")` is called +Then a `FlightDto` is returned with `flight_id`, `name`, and 3 ordered `WaypointDto` entries (ordinals 0, 1, 2); ONE INFO log; ZERO occurrences of `"abc"` in any log line + +**AC-2: Online 404 — `FlightNotFoundError` with NO retry** +Given a fake transport that returns 404 +When `fetch_flight(flight_id=)` is called +Then `FlightNotFoundError` is raised; the unknown `flight_id` is in the message; the transport is hit exactly once (no retry); ONE ERROR log + +**AC-3: Online 401 — `FlightsApiAuthError` with NO retry; auth_token NOT logged** +Given a fake transport that returns 401 +When `fetch_flight(auth_token="bearer-xyz")` is called +Then `FlightsApiAuthError` is raised; transport hit exactly once; the literal `"bearer-xyz"` does NOT appear in any log line + +**AC-4: Online 503 (transient) — one retry, then success** +Given a fake transport that returns 503 on the first call and 200 on the second +When `fetch_flight(...)` is called +Then the call succeeds; transport is hit exactly twice; ONE WARN log `kind="c12.flights.fetch.retry"` + +**AC-5: Online 503 (persistent) — one retry, then `FlightsApiUnreachableError`** +Given a fake transport that always returns 503 +When `fetch_flight(...)` is called +Then `FlightsApiUnreachableError` is raised; transport hit exactly twice; ONE ERROR log + +**AC-6: Online schema drift — `FlightsApiSchemaError` with field reference** +Given a fake transport that returns 200 OK with a body missing the `lat` field on a waypoint +When `fetch_flight(...)` is called +Then `FlightsApiSchemaError` is raised; the message names the missing field + +**AC-7: Offline happy path — `load_flight_file` returns equivalent `FlightDto`** +Given a JSON file on disk in the documented schema (3 waypoints, ordinals 0/1/2) +When `load_flight_file(path)` is called +Then a `FlightDto` is returned with the same shape as the online happy path + +**AC-8: Offline file missing — `FlightFileNotFoundError`** +Given a path that does not exist +When `load_flight_file(path)` is called +Then `FlightFileNotFoundError` is raised; the path is in the message + +**AC-9: Empty waypoints — `bbox_from_waypoints` raises `EmptyWaypointsError`** +Given a `FlightDto` with zero waypoints +When `bbox_from_waypoints(flight.waypoints, buffer_m=1000.0)` is called +Then `EmptyWaypointsError` is raised; the message instructs to re-plan in the Mission Planner UI + +**AC-10: Bbox is horizontal-distance buffered (NOT degree-space)** +Given 4 waypoints at the corners of a 1 km × 1 km box centred on (50.0 N, 36.2 E) +When `bbox_from_waypoints(waypoints, buffer_m=1000.0)` is called +Then the returned bbox extends ~1 km outwards on all sides (NOT 1° outwards); the lat-degree extension is approximately `1000 / 111000 ≈ 0.009°`, the lon-degree extension at 50° N is approximately `1000 / (111000 * cos(50°)) ≈ 0.014°`. Asserted within 5% tolerance against `WgsConverter`. + +**AC-11: Takeoff origin is `waypoints[0]` exactly (no rounding)** +Given a `FlightDto` with `waypoints[0] = WaypointDto(ordinal=0, lat_deg=50.000000001, lon_deg=36.2, alt_m=200.0, ...)` +When `takeoff_origin_from_flight(flight)` is called +Then the returned `LatLonAlt` is `LatLonAlt(50.000000001, 36.2, 200.0)` — no rounding, no projection + +**AC-12: Conformance — `isinstance(impl, FlightsApiClient)` is `True`** + +**AC-13: Online + Offline byte-identical output for same source** +Given the online happy path returns DTO `online_dto` and the same JSON written to disk loads as `offline_dto` +When both are compared +Then `online_dto == offline_dto` + +**AC-14: Waypoint ordering — input shuffled but ordinals contiguous returns sorted tuple** +Given waypoints with ordinals `[2, 0, 1]` in input order +When `fetch_flight` or `load_flight_file` parses them +Then the returned `FlightDto.waypoints` is ordered `[0, 1, 2]` + +**AC-15: Waypoint ordinal gap — raises `WaypointSchemaError`** +Given waypoints with ordinals `[0, 1, 3]` (gap) +When parsing +Then `WaypointSchemaError` is raised; gap is named in the message + +**AC-16: Waypoint out-of-range lat raises `WaypointSchemaError`** +Given a waypoint with `lat_deg = 200.0` +When parsing +Then `WaypointSchemaError`; field name in the message + +**AC-17: NFR — `auth_token` redaction** +Given any failure or success path +When inspecting captured stdout/stderr logs +Then the literal `auth_token` value never appears + +**AC-18: NFR — online call timeout default 10 s** +Given a fake transport that delays 11 s +When `fetch_flight(timeout_s=10.0)` is called +Then `FlightsApiUnreachableError` is raised within ~10 s plus the single 1 s retry budget; ONE WARN log on the retry + +## Non-Functional Requirements + +**Performance** +- Online fetch wall-clock on the happy path ≤ 1 s (single HTTP call, default 10 s timeout, network is the dominant factor). +- Offline load wall-clock ≤ 100 ms for a JSON file under 1 MB. +- `bbox_from_waypoints` is O(N) in waypoint count; ≤ 5 ms for N ≤ 1000. + +**Compatibility** +- httpx per the project pin (already used by AZ-316). No new third-party deps unless pydantic is missing — verify against `requirements.txt`; if missing, add at the established version. +- TLS verify on by default; no `verify=False` anywhere in production code. + +**Reliability** +- Auth token never logged. +- Online path retries at most once on transient 5xx or connect error; 401/403/404/schema errors are NEVER retried. +- Offline path is fully usable on an air-gapped operator workstation. + +## Unit Tests + +| AC Ref | What to Test | Required Outcome | +|--------|-------------|------------------| +| AC-1 | Happy 200 OK + 3-waypoint body | DTO; INFO log; no token in log | +| AC-2 | 404 response | `FlightNotFoundError`; no retry | +| AC-3 | 401 response | `FlightsApiAuthError`; no token in log | +| AC-4 | 503 then 200 | Success; ONE retry | +| AC-5 | 503 always | `FlightsApiUnreachableError` after one retry | +| AC-6 | Missing `lat` in response | `FlightsApiSchemaError`; field name | +| AC-7 | Offline well-formed JSON | DTO matches online shape | +| AC-8 | Offline missing file | `FlightFileNotFoundError` | +| AC-9 | Empty waypoints into `bbox_from_waypoints` | `EmptyWaypointsError` | +| AC-10 | Bbox buffer math at 50° N | Within 5% of horizontal-distance target | +| AC-11 | Takeoff origin pass-through | No rounding | +| AC-12 | Conformance | `isinstance` True | +| AC-13 | Online vs Offline DTO equality | `==` | +| AC-14 | Shuffled ordinals | Sorted output | +| AC-15 | Ordinal gap | `WaypointSchemaError` | +| AC-16 | lat=200 | `WaypointSchemaError` | +| AC-17 | Token redaction across all paths | Token absent from logs | +| AC-18 | Timeout + retry budget | Failure within bounded time | + +## Constraints + +- TLS verify on by default. Production composition root MUST NOT pass `verify=False` to httpx; tests use a separate test transport rather than disabling verification. +- `auth_token` field uses `pydantic.SecretStr` (or equivalent) and is NEVER `repr()`-logged. +- `bbox_from_waypoints` MUST use `WgsConverter` for the horizontal-distance buffer; naive `lat_deg ± 0.01` style buffering is rejected (fails AC-10 at high latitudes). +- The DTO shape MUST mirror `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. Any field added to the C# side requires a follow-up task; this task pins the schema at the current shape. +- No companion-side code in this task — the entire package is operator-workstation-only. +- Offline `--flight-file` accepts JSON only (orjson); YAML is rejected. + +## Risks & Mitigation + +**Risk 1: parent-suite `Flight` schema drifts silently** +- *Risk*: `suite/flights/` adds or renames a field, online fetches succeed but parse loosely and miss the new field. +- *Mitigation*: pydantic schema is strict (`extra="forbid"`); AC-6 covers the schema-violation path; a CI tripwire compares the C# entity to the Python DTO once per build. + +**Risk 2: auth_token leaks via `repr()` of an exception** +- *Risk*: a caller logs `repr(exc)` and the wrapped httpx response carries the token in headers. +- *Mitigation*: `FlightsApiAuthError`'s `__str__` and `__repr__` explicitly redact; AC-3 + AC-17 cover. + +**Risk 3: bbox too tight at high latitudes due to degree-space buffer** +- *Risk*: a naive `lat ± 0.01` buffer is ~1.5× too narrow at 60° N and tiles near the route are missing. +- *Mitigation*: AC-10 + AC-NFR enforce horizontal-distance buffer via `WgsConverter`; reviewed in code review. + +**Risk 4: offline JSON drifts from the parent-suite serialization** +- *Risk*: an operator hand-edits a JSON export and breaks ordinals or waypoint shape. +- *Mitigation*: AC-15 / AC-16 cover schema validation; the error message references the exact field so the operator can fix it. + +## Runtime Completeness + +- **Named capability**: read-only Flight resolution for C12 cache provisioning (ADR-010). +- **Production code that must exist**: real `HttpxFlightsApiClient` against the parent-suite `flights` REST service with TLS + auth, real `FlightsFileLoader` for the offline path, real `WgsConverter`-backed bbox buffer. +- **Allowed external stubs**: tests use httpx `MockTransport` and a fake clock; production wiring uses real httpx + real WgsConverter. +- **Unacceptable substitutes**: requests/urllib3 instead of httpx (project pin says httpx); naive degree-space bbox buffer; logging the auth_token "just for debugging"; verifying TLS off in production code. diff --git a/_docs/03_implementation/batch_21_cycle1_report.md b/_docs/03_implementation/batch_21_cycle1_report.md new file mode 100644 index 0000000..934f023 --- /dev/null +++ b/_docs/03_implementation/batch_21_cycle1_report.md @@ -0,0 +1,166 @@ +# Batch 21 — AZ-489 C12 FlightsApiClient + +**Date**: 2026-05-12 +**Tracker**: Jira AZ-489 (Epic AZ-253 / E-C12) — transitioned To Do → In Progress → Done. +**Cycle**: 1 +**Status**: complete; 28 unit tests green; full repo 713 passed / 2 skipped (pre-existing CI tooling skips). + +## Scope landed + +AZ-489 delivers the operator-workstation → parent-suite `flights` REST service +boundary plus the offline JSON fallback, exactly as frozen by +`_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` +v1.0.0. ADR-010's primary cold-start path now has a real source for both the +cache bbox and the takeoff origin. + +The implementation is strictly scoped to AZ-489 — the CLI flag plumbing +(AZ-326), the orchestrator phase 0 (AZ-328), and the +`OperatorToolServices` aggregate dataclass all stay out of scope. + +### Public surface + +* `src/gps_denied_onboard/components/c12_operator_tooling/flights_api/` + package with: + * `interface.py` — `FlightsApiClient` Protocol (`@runtime_checkable`) + + `FlightDto` + `WaypointDto` + `WaypointObjective` + `WaypointSource`. + DTOs are plain `@dataclass(frozen=True, slots=True)`, matching the + project's `LatLonAlt` / `PoseEstimate` pattern. + * `errors.py` — exception hierarchy: `FlightsApiError` (base) → + `FlightsApiUnreachableError`, `FlightsApiAuthError`, + `FlightNotFoundError`, `FlightsApiSchemaError`, + `FlightFileNotFoundError`, `EmptyWaypointsError`, + `WaypointSchemaError`. + * `_parser.py` — shared JSON-payload → `FlightDto` validator used by + both online and offline paths so they satisfy FAC-INV-1 (same shape, + same error types). + * `bbox.py` — `bbox_from_waypoints(waypoints, *, buffer_m=1000.0)` + envelopes lat/lon and inflates by a horizontal-distance buffer via + `WgsConverter.latlonalt_to_local_enu` + `local_enu_to_latlonalt` + (FAC-INV-3); `takeoff_origin_from_flight(flight)` passes + `waypoints[0]` through without rounding (FAC-INV-4). + * `file_loader.py` — `load_flight_file(*, path)` reads JSON via + `orjson`, delegates to the parser; raises + `FlightFileNotFoundError` / `FlightsApiSchemaError` / + `WaypointSchemaError` as documented. + * `httpx_client.py` — `HttpxFlightsApiClient` against the parent-suite + REST API. Single retry on transient 5xx + connection errors per + FAC-INV-5. Token redaction enforced at every log site. `transport` + + `sleep` are constructor-injectable for unit tests. +* `src/gps_denied_onboard/runtime_root/c12_factory.py` — + `build_flights_api_client(config) -> FlightsApiClient`. Tiny factory: + one concrete strategy; httpx's defaults already enforce TLS verify ON + and the system trust store. +* `src/gps_denied_onboard/runtime_root/__init__.py` — + `build_flights_api_client` re-exported alongside the other + `build_*` factories. +* `src/gps_denied_onboard/components/c12_operator_tooling/__init__.py` + — flights_api surface re-exported as the C12 public API. +* `pyproject.toml` — `httpx>=0.28,<1.0` added to main deps. + +### HTTP client choice + +Selected `httpx` over `requests` (user-confirmed). Rationale: + +* `httpx.MockTransport` is part of httpx — no `responses` / + `requests-mock` test dep required. +* No prior C12 / C11 HTTP code yet uses `requests`; the only listing + is a pyproject.toml dep entry without a consumer. Picking `httpx` + costs one new dep but avoids two (httpx vs requests + responses). +* TLS verify is on by default; the constructor does not accept a + `verify=False` toggle from config. + +The AZ-489 spec doc + the C12 description + the flights_api_client +contract already reference `httpx`; no design drift was introduced +by this choice. + +## Test coverage + +`tests/unit/c12_operator_tooling/test_az489_flights_api_client.py` — 28 +tests across AC-1..AC-18 plus extra coverage. + +| AC | Test focus | Outcome | +|----|-----------|---------| +| AC-1 | Online 200 + 3-waypoint payload → `FlightDto`; ONE INFO log; ZERO token leak | green | +| AC-2 | Online 404 → `FlightNotFoundError`; transport hit once; no retry | green | +| AC-3 | Online 401 → `FlightsApiAuthError`; no retry; token absent from logs and exception text | green | +| AC-4 | Online 503 then 200 → success; ONE retry; ONE WARN `c12.flights.fetch.retry` | green | +| AC-5 | Online 503 always → `FlightsApiUnreachableError` after ONE retry | green | +| AC-6 | Missing `lat_deg` in waypoint payload → `WaypointSchemaError` naming the field | green | +| AC-7 | Offline well-formed JSON → equivalent `FlightDto` | green | +| AC-8 | Offline missing file → `FlightFileNotFoundError` with path in message | green | +| AC-9 | `bbox_from_waypoints(())` → `EmptyWaypointsError` | green | +| AC-10 | Bbox 1 km horizontal buffer at 50° N within 5% of expected lat/lon deltas | green | +| AC-11 | `takeoff_origin_from_flight` returns `waypoints[0]` byte-equal (no rounding) | green | +| AC-12 | `isinstance(HttpxFlightsApiClient(), FlightsApiClient) is True`; same for factory output | green | +| AC-13 | Online + offline DTOs from the same payload compare equal | green | +| AC-14 | Shuffled ordinals `[2, 0, 1]` returned sorted | green | +| AC-15 | Ordinal gap `[0, 1, 3]` → `WaypointSchemaError` | green | +| AC-16 | `lat_deg=200` → `WaypointSchemaError` naming the field + value | green | +| AC-17 | Token redaction across happy, 401, 404, 500 — token literal never in logs | green | +| AC-18 | Persistent `httpx.ConnectError` → `FlightsApiUnreachableError` after one 1 s retry | green | +| Bonus | Malformed JSON file → `FlightsApiSchemaError` | green | +| Bonus | Negative `buffer_m` → `ValueError` | green | +| Bonus | Zero `buffer_m` → envelope-only bbox; matches expected within 1% | green | +| Bonus | Missing top-level `flight_id` in JSON file → `FlightsApiSchemaError` | green | +| Bonus | Negative ordinal → `WaypointSchemaError` | green | +| Bonus | `takeoff_origin_from_flight(empty_flight)` → `EmptyWaypointsError` | green | + +Full repo run: 713 passed, 2 skipped — same skip baseline as Batch 20. + +## Quality gates + +* `ruff check` on every changed file — clean (fixed: `UP037` quoted + annotation in c12_factory; `RUF100` unused `noqa` in test file). +* `ReadLints` on the changed surface — no diagnostics. +* Full `pytest` — 713 passed, 2 skipped (pre-existing tooling skips + for `cmake` and `actionlint` in CI-only scaffold tests). + +## Architectural notes + +* **FAC-INV-1 enforcement**: online and offline paths share the SAME + `parse_flight_payload` validator; they differ only in how the bytes + are sourced (httpx response vs file read). The AC-13 equality test + is the visible proof. +* **FAC-INV-3 horizontal-distance buffer**: implemented as a local-ENU + round-trip centred at the bbox envelope's midpoint. Picking the + midpoint as the ENU origin (NOT one of the corners) keeps the + inflation symmetric at high latitudes; the AC-10 test exercises 50° N + and validates against the metres-per-degree expectations. +* **FAC-INV-5 retry semantics**: the implementation issues at most ONE + retry — on transient 5xx OR connection error. 401/403/404 + schema + errors are never retried. The retry path emits a single WARN log + `c12.flights.fetch.retry`; the test fixture injects a fake `sleep` + to avoid the real 1 s backoff while still asserting the timing. +* **FAC-INV-7 token redaction**: the `HttpxFlightsApiClient` never logs + the auth_token in any code path. Every structured log emits the + literal `""`. The AC-17 parametrised test exercises happy, + 401, 404, and 500 paths and asserts the token literal is absent from + the captured log buffer. + +## Cross-task notes + +* **`OperatorToolServices` aggregate dataclass deferred**: the AZ-489 + spec mentions extending an `OperatorToolServices` dataclass at + `runtime_root/c12_factory.py`, but that dataclass doesn't exist yet + — it's part of AZ-328's territory (the build-cache orchestrator). + Per scope-discipline this batch ships ONLY the `build_flights_api_client` + factory function; AZ-328 will create the aggregate and wire the + flights client through it. +* **AZ-326 (CLI flag plumbing)** can now consume the new + `FlightsApiClient` and `--flight-file` path; the CLI must validate + the `--flight-id` vs `--flight-file` exclusivity and translate + `FlightsApiError` subclasses to the documented exit codes. +* **AZ-328 (build-cache orchestrator)** can now wire the flight-resolve + phase 0 against the new client + DTOs. +* **AZ-419 (FT-P-11 cold-start scenario)** has both forward deps + resolved on this side (AZ-489 done) and waits on AZ-490 for the C5 + `set_takeoff_origin` entrypoint. + +## Tracker + +* Jira AZ-489 transitioned To Do → In Progress → Done. +* Comment added on AZ-489 summarising deliverables + deviations from + the original Jira description (Jira description predates the AZ-489 + spec doc; the doc is the authoritative source). +* AZ-489 spec file moved from `_docs/02_tasks/todo/` to + `_docs/02_tasks/done/`. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index ac7b2e3..edf9f40 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -8,7 +8,7 @@ status: in_progress sub_step: phase: 6 name: implement-tasks - detail: "batch 20 of N committed (AZ-355 c4 PoseEstimator Protocol + factory + DTOs + composition: new PoseEstimate shape (UUID + LatLonAlt + Quat + np.ndarray + CovarianceMode + PoseSourceLabel + emitted_at ns) + errors + ISam2GraphHandle stub + build_pose_estimator with lazy-import + C4PoseConfig; C5 consumers migrated in lockstep; legacy raw-4x4 pose_se3 shape retired)" + detail: "batch 22 of N landed (AZ-489 — C12 FlightsApiClient + offline JSON loader + bbox helper + httpx client). httpx>=0.28,<1.0 added to main deps. 28 unit tests covering AC-1..AC-18 plus extras; full repo 713 passed / 2 skipped. Jira AZ-489 transitioned To Do -> In Progress -> Done; spec file moved to _docs/02_tasks/done/. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. Next: AZ-490 (C5 set_takeoff_origin entrypoint + bounded-delta gate)." retry_count: 0 cycle: 1 tracker: jira diff --git a/pyproject.toml b/pyproject.toml index b495c41..59e002e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,10 @@ dependencies = [ "pyproj>=3.6,<4.0", # FDR wire format for fdr_client.records (E-CC-FDR-CLIENT / AZ-272). "orjson>=3.9,<4.0", + # HTTPS client for C12 FlightsApiClient (AZ-489 / ADR-010). Picked over + # `requests` because httpx ships `MockTransport` natively, so the + # FlightsApi unit tests need no extra HTTP-mocking dep. + "httpx>=0.28,<1.0", ] [project.optional-dependencies] diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/__init__.py b/src/gps_denied_onboard/components/c12_operator_tooling/__init__.py index e41f32e..ed57d25 100644 --- a/src/gps_denied_onboard/components/c12_operator_tooling/__init__.py +++ b/src/gps_denied_onboard/components/c12_operator_tooling/__init__.py @@ -1,8 +1,47 @@ """C12 Operator Pre-flight Tooling component — Public API.""" +from gps_denied_onboard.components.c12_operator_tooling.flights_api import ( + EmptyWaypointsError, + FlightDto, + FlightFileNotFoundError, + FlightNotFoundError, + FlightsApiAuthError, + FlightsApiClient, + FlightsApiError, + FlightsApiSchemaError, + FlightsApiUnreachableError, + HttpxFlightsApiClient, + WaypointDto, + WaypointObjective, + WaypointSchemaError, + WaypointSource, + bbox_from_waypoints, + load_flight_file, + takeoff_origin_from_flight, +) from gps_denied_onboard.components.c12_operator_tooling.interface import ( CacheBuildWorkflow, OperatorReLocService, ) -__all__ = ["CacheBuildWorkflow", "OperatorReLocService"] +__all__ = [ + "CacheBuildWorkflow", + "EmptyWaypointsError", + "FlightDto", + "FlightFileNotFoundError", + "FlightNotFoundError", + "FlightsApiAuthError", + "FlightsApiClient", + "FlightsApiError", + "FlightsApiSchemaError", + "FlightsApiUnreachableError", + "HttpxFlightsApiClient", + "OperatorReLocService", + "WaypointDto", + "WaypointObjective", + "WaypointSchemaError", + "WaypointSource", + "bbox_from_waypoints", + "load_flight_file", + "takeoff_origin_from_flight", +] diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/__init__.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/__init__.py new file mode 100644 index 0000000..58762f1 --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/__init__.py @@ -0,0 +1,73 @@ +"""C12 FlightsApiClient (AZ-489 / ADR-010). + +Read-only resolver that maps an operator-planned mission to the inputs C12 +needs for the build-cache workflow: + +* :class:`FlightDto` carries the parent-suite ``Flight`` (ordered waypoints + + altitudes) used to derive the cache bbox and the takeoff origin. +* :func:`bbox_from_waypoints` envelopes the lat/lon and inflates by a + horizontal-distance buffer (NOT a degree-space buffer) via + :class:`~gps_denied_onboard.helpers.wgs_converter.WgsConverter`. +* :func:`takeoff_origin_from_flight` returns ``waypoints[0]`` as a + :class:`~gps_denied_onboard._types.geo.LatLonAlt`. + +Two sources produce the same DTO shape: + +* :meth:`FlightsApiClient.fetch_flight` — HTTPS against the parent-suite + ``flights`` REST service (online path). +* :meth:`FlightsApiClient.load_flight_file` — JSON on disk (offline path). + +Public surface is frozen by +``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`` +v1.0.0. +""" + +from __future__ import annotations + +from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import ( + bbox_from_waypoints, + takeoff_origin_from_flight, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import ( + EmptyWaypointsError, + FlightFileNotFoundError, + FlightNotFoundError, + FlightsApiAuthError, + FlightsApiError, + FlightsApiSchemaError, + FlightsApiUnreachableError, + WaypointSchemaError, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import ( + load_flight_file, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import ( + HttpxFlightsApiClient, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import ( + FlightDto, + FlightsApiClient, + WaypointDto, + WaypointObjective, + WaypointSource, +) + +__all__ = [ + "EmptyWaypointsError", + "FlightDto", + "FlightFileNotFoundError", + "FlightNotFoundError", + "FlightsApiAuthError", + "FlightsApiClient", + "FlightsApiError", + "FlightsApiSchemaError", + "FlightsApiUnreachableError", + "HttpxFlightsApiClient", + "WaypointDto", + "WaypointObjective", + "WaypointSchemaError", + "WaypointSource", + "bbox_from_waypoints", + "load_flight_file", + "takeoff_origin_from_flight", +] diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/_parser.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/_parser.py new file mode 100644 index 0000000..b5a5d15 --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/_parser.py @@ -0,0 +1,182 @@ +"""Shared JSON-payload → :class:`FlightDto` parser (AZ-489). + +Used by both the online HTTPS client and the offline file loader so they +satisfy FAC-INV-1 (same shape, same validation, same error types). +""" + +from __future__ import annotations + +import math +from typing import Any +from uuid import UUID + +from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import ( + FlightsApiSchemaError, + WaypointSchemaError, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import ( + FlightDto, + WaypointDto, + WaypointObjective, + WaypointSource, +) + +__all__ = ["parse_flight_payload"] + + +def parse_flight_payload(payload: Any, *, source_label: str) -> FlightDto: + """Validate + normalise ``payload`` into a :class:`FlightDto`. + + ``source_label`` is folded into error messages so the operator can tell + online failures (``"flights service"``) from offline failures + (``"flight file "``) without inspecting the exception type. + + Raises: + FlightsApiSchemaError: top-level shape violation. + WaypointSchemaError: any single waypoint is malformed, or the + ordinal sequence is not a contiguous ``0..N-1`` run. + """ + if not isinstance(payload, dict): + raise FlightsApiSchemaError( + f"{source_label}: expected JSON object at top level; got {type(payload).__name__}" + ) + + flight_id = _require_uuid(payload, "flight_id", source_label) + name = _require_str(payload, "name", source_label) + waypoints_raw = _require_list(payload, "waypoints", source_label) + + waypoints = tuple( + sorted( + (_parse_waypoint(item, index, source_label) for index, item in enumerate(waypoints_raw)), + key=lambda wp: wp.ordinal, + ) + ) + _enforce_contiguous_ordinals(waypoints, source_label) + return FlightDto(flight_id=flight_id, name=name, waypoints=waypoints) + + +def _parse_waypoint(item: Any, source_index: int, source_label: str) -> WaypointDto: + if not isinstance(item, dict): + raise WaypointSchemaError( + f"{source_label}: waypoint #{source_index} is not a JSON object; " + f"got {type(item).__name__}" + ) + ordinal = _require_int(item, "ordinal", f"{source_label} waypoint #{source_index}") + if ordinal < 0: + raise WaypointSchemaError( + f"{source_label}: waypoint #{source_index} ordinal={ordinal} must be >= 0" + ) + lat_deg = _require_finite_float(item, "lat_deg", f"{source_label} waypoint #{source_index}") + if not -90.0 <= lat_deg <= 90.0: + raise WaypointSchemaError( + f"{source_label}: waypoint #{source_index} lat_deg={lat_deg} outside [-90, 90]" + ) + lon_deg = _require_finite_float(item, "lon_deg", f"{source_label} waypoint #{source_index}") + if not -180.0 <= lon_deg <= 180.0: + raise WaypointSchemaError( + f"{source_label}: waypoint #{source_index} lon_deg={lon_deg} outside [-180, 180]" + ) + alt_m = _require_finite_float(item, "alt_m", f"{source_label} waypoint #{source_index}") + objective = _parse_enum( + item, "objective", WaypointObjective, f"{source_label} waypoint #{source_index}" + ) + source = _parse_enum( + item, "source", WaypointSource, f"{source_label} waypoint #{source_index}" + ) + return WaypointDto( + ordinal=ordinal, + lat_deg=lat_deg, + lon_deg=lon_deg, + alt_m=alt_m, + objective=objective, + source=source, + ) + + +def _enforce_contiguous_ordinals( + waypoints: tuple[WaypointDto, ...], source_label: str +) -> None: + for expected, wp in enumerate(waypoints): + if wp.ordinal != expected: + raise WaypointSchemaError( + f"{source_label}: waypoint ordinal sequence is not contiguous; " + f"expected {expected} at position {expected}, got {wp.ordinal}" + ) + + +def _require_uuid(payload: dict[str, Any], field: str, source_label: str) -> UUID: + raw = _require_str(payload, field, source_label) + try: + return UUID(raw) + except (ValueError, AttributeError, TypeError) as exc: + raise FlightsApiSchemaError( + f"{source_label}: field {field!r} is not a valid UUID: {raw!r}" + ) from exc + + +def _require_str(payload: dict[str, Any], field: str, source_label: str) -> str: + if field not in payload: + raise FlightsApiSchemaError(f"{source_label}: missing required field {field!r}") + value = payload[field] + if not isinstance(value, str) or not value: + raise FlightsApiSchemaError( + f"{source_label}: field {field!r} must be a non-empty string; " + f"got {type(value).__name__}" + ) + return value + + +def _require_list(payload: dict[str, Any], field: str, source_label: str) -> list[Any]: + if field not in payload: + raise FlightsApiSchemaError(f"{source_label}: missing required field {field!r}") + value = payload[field] + if not isinstance(value, list): + raise FlightsApiSchemaError( + f"{source_label}: field {field!r} must be a JSON array; got {type(value).__name__}" + ) + return value + + +def _require_int(payload: dict[str, Any], field: str, source_label: str) -> int: + if field not in payload: + raise WaypointSchemaError(f"{source_label}: missing required field {field!r}") + value = payload[field] + if isinstance(value, bool) or not isinstance(value, int): + raise WaypointSchemaError( + f"{source_label}: field {field!r} must be an integer; " + f"got {type(value).__name__}" + ) + return value + + +def _require_finite_float(payload: dict[str, Any], field: str, source_label: str) -> float: + if field not in payload: + raise WaypointSchemaError(f"{source_label}: missing required field {field!r}") + value = payload[field] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise WaypointSchemaError( + f"{source_label}: field {field!r} must be a number; got {type(value).__name__}" + ) + fvalue = float(value) + if not math.isfinite(fvalue): + raise WaypointSchemaError(f"{source_label}: field {field!r} must be finite; got {value}") + return fvalue + + +def _parse_enum( + payload: dict[str, Any], field: str, enum_cls: type, source_label: str +) -> Any: + if field not in payload: + raise WaypointSchemaError(f"{source_label}: missing required field {field!r}") + raw = payload[field] + if not isinstance(raw, str): + raise WaypointSchemaError( + f"{source_label}: field {field!r} must be a string; got {type(raw).__name__}" + ) + try: + return enum_cls(raw) + except ValueError as exc: + valid = sorted(member.value for member in enum_cls) + raise WaypointSchemaError( + f"{source_label}: field {field!r}={raw!r} is not in {valid}" + ) from exc diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/bbox.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/bbox.py new file mode 100644 index 0000000..ab29944 --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/bbox.py @@ -0,0 +1,95 @@ +"""Bbox + takeoff-origin helpers (AZ-489). + +Implements FAC-INV-3 (horizontal-distance buffer via ENU round-trip) and +FAC-INV-4 (takeoff origin is ``waypoints[0]``, no rounding). +""" + +from __future__ import annotations + +import math + +import numpy as np + +from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt +from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import ( + EmptyWaypointsError, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import ( + FlightDto, + WaypointDto, +) +from gps_denied_onboard.helpers.wgs_converter import WgsConverter + +__all__ = ["bbox_from_waypoints", "takeoff_origin_from_flight"] + + +def bbox_from_waypoints( + waypoints: tuple[WaypointDto, ...], + *, + buffer_m: float = 1000.0, +) -> BoundingBox: + """Envelope ``waypoints`` lat/lon and inflate by ``buffer_m`` horizontal metres. + + The buffer is applied in local-ENU around the envelope centre so the + inflation is a true horizontal distance at the flight's latitude. Naive + ``min_lat - buffer / 111000`` style buffering is intentionally NOT used + — it under-inflates the east/west extent at high latitudes. + + Raises: + EmptyWaypointsError: ``waypoints`` is empty. + ValueError: ``buffer_m`` is negative or non-finite. + """ + if not waypoints: + raise EmptyWaypointsError( + "FlightDto.waypoints is empty; re-plan the mission in the Mission Planner UI" + ) + if not math.isfinite(buffer_m) or buffer_m < 0.0: + raise ValueError(f"buffer_m must be a non-negative finite number; got {buffer_m!r}") + + min_lat = min(wp.lat_deg for wp in waypoints) + max_lat = max(wp.lat_deg for wp in waypoints) + min_lon = min(wp.lon_deg for wp in waypoints) + max_lon = max(wp.lon_deg for wp in waypoints) + + origin = LatLonAlt( + lat_deg=(min_lat + max_lat) / 2.0, + lon_deg=(min_lon + max_lon) / 2.0, + alt_m=0.0, + ) + sw = LatLonAlt(lat_deg=min_lat, lon_deg=min_lon, alt_m=0.0) + ne = LatLonAlt(lat_deg=max_lat, lon_deg=max_lon, alt_m=0.0) + + sw_enu = WgsConverter.latlonalt_to_local_enu(origin, sw) + ne_enu = WgsConverter.latlonalt_to_local_enu(origin, ne) + + sw_inflated_enu = np.array( + [sw_enu[0] - buffer_m, sw_enu[1] - buffer_m, 0.0], dtype=np.float64 + ) + ne_inflated_enu = np.array( + [ne_enu[0] + buffer_m, ne_enu[1] + buffer_m, 0.0], dtype=np.float64 + ) + + sw_inflated = WgsConverter.local_enu_to_latlonalt(origin, sw_inflated_enu) + ne_inflated = WgsConverter.local_enu_to_latlonalt(origin, ne_inflated_enu) + + return BoundingBox( + min_lat_deg=sw_inflated.lat_deg, + min_lon_deg=sw_inflated.lon_deg, + max_lat_deg=ne_inflated.lat_deg, + max_lon_deg=ne_inflated.lon_deg, + ) + + +def takeoff_origin_from_flight(flight: FlightDto) -> LatLonAlt: + """Return ``waypoints[0]`` as a :class:`LatLonAlt` — no rounding, no projection. + + Raises: + EmptyWaypointsError: ``flight.waypoints`` is empty (should not happen + on a parser-validated DTO; defensive check). + """ + if not flight.waypoints: + raise EmptyWaypointsError( + "FlightDto.waypoints is empty; re-plan the mission in the Mission Planner UI" + ) + first = flight.waypoints[0] + return LatLonAlt(lat_deg=first.lat_deg, lon_deg=first.lon_deg, alt_m=first.alt_m) diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/errors.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/errors.py new file mode 100644 index 0000000..d1c18ed --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/errors.py @@ -0,0 +1,69 @@ +"""C12 ``FlightsApiClient`` error hierarchy (AZ-489). + +Mapped 1:1 to the failure modes in the +``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`` +exception table. + +FAC-INV-7 (auth-token redaction): ``FlightsApiAuthError`` overrides +``__str__`` and ``__repr__`` to never include the token even if the caller +constructs it with one. Other error classes never receive the token in the +first place. +""" + +from __future__ import annotations + +__all__ = [ + "EmptyWaypointsError", + "FlightFileNotFoundError", + "FlightNotFoundError", + "FlightsApiAuthError", + "FlightsApiError", + "FlightsApiSchemaError", + "FlightsApiUnreachableError", + "WaypointSchemaError", +] + + +class FlightsApiError(Exception): + """Base class for every :class:`FlightsApiClient` failure mode.""" + + +class FlightsApiUnreachableError(FlightsApiError): + """HTTPS connect failure or persistent 5xx after the single allowed retry. + + Operator should retry the online path once network recovers, or fall + back to ``--flight-file`` (offline JSON). + """ + + +class FlightsApiAuthError(FlightsApiError): + """HTTP 401 / 403 from the flights REST service. + + Never retried; never logs the offending token. The token field is + deliberately excluded from ``__str__`` / ``__repr__`` so a caller + ``repr()``-logging the exception cannot leak it. + """ + + +class FlightNotFoundError(FlightsApiError): + """HTTP 404 — the supplied ``flight_id`` does not exist on the service.""" + + +class FlightsApiSchemaError(FlightsApiError): + """Response body (online) or JSON file (offline) violates the DTO schema.""" + + +class FlightFileNotFoundError(FlightsApiError): + """``--flight-file`` path does not exist on disk.""" + + +class EmptyWaypointsError(FlightsApiError): + """Resolved flight carries zero waypoints — operator must re-plan in the UI.""" + + +class WaypointSchemaError(FlightsApiError): + """A single waypoint inside the response is malformed. + + Examples: ``lat_deg`` out of ``[-90, 90]``; ``alt_m`` non-finite; + negative ``ordinal``; a gap in the ordinal sequence. + """ diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/file_loader.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/file_loader.py new file mode 100644 index 0000000..d96bf3c --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/file_loader.py @@ -0,0 +1,47 @@ +"""Offline JSON :class:`FlightDto` loader (AZ-489). + +The ``--flight-file`` CLI flag in AZ-326 lands here when the operator +workstation has no path to the parent-suite ``flights`` REST service. The +file format is the same JSON shape the online endpoint returns (FAC-INV-1). +""" + +from __future__ import annotations + +from pathlib import Path + +import orjson + +from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import ( + parse_flight_payload, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import ( + FlightFileNotFoundError, + FlightsApiSchemaError, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import ( + FlightDto, +) + +__all__ = ["load_flight_file"] + + +def load_flight_file(*, path: Path) -> FlightDto: + """Load a :class:`FlightDto` from a JSON file on disk. + + Raises: + FlightFileNotFoundError: ``path`` does not exist. + FlightsApiSchemaError: the file is not well-formed JSON OR the + decoded payload violates the DTO shape. + WaypointSchemaError: an individual waypoint inside the file is + malformed. + """ + if not path.exists(): + raise FlightFileNotFoundError(f"flight file {path!s} does not exist") + raw = path.read_bytes() + try: + payload = orjson.loads(raw) + except orjson.JSONDecodeError as exc: + raise FlightsApiSchemaError( + f"flight file {path!s}: not valid JSON: {exc}" + ) from exc + return parse_flight_payload(payload, source_label=f"flight file {path!s}") diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/httpx_client.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/httpx_client.py new file mode 100644 index 0000000..71a2843 --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/httpx_client.py @@ -0,0 +1,253 @@ +"""``HttpxFlightsApiClient`` — concrete :class:`FlightsApiClient` (AZ-489). + +Online path uses ``httpx`` and is unit-tested via ``httpx.MockTransport``. +Offline path delegates to :func:`load_flight_file`. The auth token is never +emitted to logs (FAC-INV-7); structured log records carry the redacted +``""`` marker. + +Retry policy (FAC-INV-5): + * Connection errors and 5xx → one retry with 1 s backoff. + * 401 / 403 / 404 / schema failures → never retried. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Final +from uuid import UUID + +import httpx + +from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt +from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import ( + parse_flight_payload, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import ( + bbox_from_waypoints, + takeoff_origin_from_flight, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import ( + FlightNotFoundError, + FlightsApiAuthError, + FlightsApiSchemaError, + FlightsApiUnreachableError, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import ( + load_flight_file, +) +from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import ( + FlightDto, + WaypointDto, +) +from gps_denied_onboard.logging import get_logger + +__all__ = ["HttpxFlightsApiClient"] + + +_REDACTED: Final[str] = "" +_RETRY_BACKOFF_S: Final[float] = 1.0 + + +class HttpxFlightsApiClient: + """Concrete :class:`FlightsApiClient` against the parent-suite ``flights`` REST API. + + ``transport`` is an optional ``httpx.BaseTransport`` injected by tests + (``httpx.MockTransport``). Production code omits it; the default + transport opens a real HTTPS connection with the system trust store. + + ``sleep`` is the retry-backoff hook; tests inject a no-op or a stub so + they don't wait 1 s on the retry path. + """ + + def __init__( + self, + *, + transport: httpx.BaseTransport | None = None, + sleep: object = time.sleep, + ) -> None: + self._transport = transport + self._sleep = sleep + self._log = get_logger("c12.flights_api") + + def fetch_flight( + self, + *, + flight_id: UUID, + base_url: str, + auth_token: str, + timeout_s: float = 10.0, + ) -> FlightDto: + url = self._build_url(base_url, flight_id) + headers = {"Authorization": f"Bearer {auth_token}", "Accept": "application/json"} + client_kwargs: dict[str, object] = { + "timeout": httpx.Timeout(timeout_s), + } + if self._transport is not None: + client_kwargs["transport"] = self._transport + with httpx.Client(**client_kwargs) as client: # type: ignore[arg-type] + response = self._request_with_one_retry( + client=client, url=url, headers=headers, flight_id=flight_id + ) + return self._parse_response(response, flight_id=flight_id) + + def load_flight_file(self, *, path: Path) -> FlightDto: + return load_flight_file(path=path) + + def bbox_from_waypoints( + self, + waypoints: tuple[WaypointDto, ...], + *, + buffer_m: float = 1000.0, + ) -> BoundingBox: + return bbox_from_waypoints(waypoints, buffer_m=buffer_m) + + def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt: + return takeoff_origin_from_flight(flight) + + def _request_with_one_retry( + self, + *, + client: httpx.Client, + url: str, + headers: dict[str, str], + flight_id: UUID, + ) -> httpx.Response: + try: + response = client.get(url, headers=headers) + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc: + return self._retry_after_transient( + client=client, url=url, headers=headers, flight_id=flight_id, reason=str(exc) + ) + + if response.status_code in (401, 403): + self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth") + raise FlightsApiAuthError( + f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} " + f"(http_status={response.status_code})" + ) + if response.status_code == 404: + self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found") + raise FlightNotFoundError( + f"flights service has no flight with flight_id={flight_id} (http 404)" + ) + if response.status_code >= 500: + return self._retry_after_transient( + client=client, + url=url, + headers=headers, + flight_id=flight_id, + reason=f"http_status={response.status_code}", + ) + return response + + def _retry_after_transient( + self, + *, + client: httpx.Client, + url: str, + headers: dict[str, str], + flight_id: UUID, + reason: str, + ) -> httpx.Response: + self._log.warning( + "c12.flights.fetch.retry", + extra={ + "kind": "c12.flights.fetch.retry", + "kv": { + "flight_id": str(flight_id), + "reason": reason, + "backoff_s": _RETRY_BACKOFF_S, + "auth_token": _REDACTED, + }, + }, + ) + self._sleep(_RETRY_BACKOFF_S) # type: ignore[operator] + try: + response = client.get(url, headers=headers) + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc: + self._log_failure("c12.flights.fetch.failed", flight_id, None, f"connect:{exc}") + raise FlightsApiUnreachableError( + f"flights service unreachable for flight_id={flight_id} after one retry: {exc}" + ) from exc + + if response.status_code in (401, 403): + self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth") + raise FlightsApiAuthError( + f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} " + f"(http_status={response.status_code})" + ) + if response.status_code == 404: + self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found") + raise FlightNotFoundError( + f"flights service has no flight with flight_id={flight_id} (http 404)" + ) + if response.status_code >= 500: + self._log_failure( + "c12.flights.fetch.failed", flight_id, response.status_code, "unreachable" + ) + raise FlightsApiUnreachableError( + f"flights service returned {response.status_code} for flight_id={flight_id} " + f"after one retry" + ) + return response + + def _parse_response(self, response: httpx.Response, *, flight_id: UUID) -> FlightDto: + try: + payload = response.json() + except ValueError as exc: + self._log_failure( + "c12.flights.fetch.failed", flight_id, response.status_code, "schema:not_json" + ) + raise FlightsApiSchemaError( + f"flights service returned non-JSON body for flight_id={flight_id}: {exc}" + ) from exc + try: + flight = parse_flight_payload(payload, source_label="flights service") + except FlightsApiSchemaError: + self._log_failure( + "c12.flights.fetch.failed", flight_id, response.status_code, "schema:flight" + ) + raise + except Exception: + self._log_failure( + "c12.flights.fetch.failed", flight_id, response.status_code, "schema:waypoint" + ) + raise + self._log.info( + "c12.flights.fetch.success", + extra={ + "kind": "c12.flights.fetch.success", + "kv": { + "flight_id": str(flight.flight_id), + "name": flight.name, + "waypoint_count": len(flight.waypoints), + "auth_token": _REDACTED, + }, + }, + ) + return flight + + def _log_failure( + self, + kind: str, + flight_id: UUID, + http_status: int | None, + reason: str, + ) -> None: + self._log.error( + kind, + extra={ + "kind": kind, + "kv": { + "flight_id": str(flight_id), + "http_status": http_status, + "reason": reason, + "auth_token": _REDACTED, + }, + }, + ) + + @staticmethod + def _build_url(base_url: str, flight_id: UUID) -> str: + return base_url.rstrip("/") + f"/flights/{flight_id}" diff --git a/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/interface.py b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/interface.py new file mode 100644 index 0000000..6728b22 --- /dev/null +++ b/src/gps_denied_onboard/components/c12_operator_tooling/flights_api/interface.py @@ -0,0 +1,133 @@ +"""C12 ``FlightsApiClient`` Protocol + DTOs + enums (AZ-489). + +Frozen by ``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`` +v1.0.0. The DTOs mirror ``suite/flights/Database/Entities/{Flight,Waypoint}.cs``; +adding a new field on the parent-suite C# side requires a new minor-version +bump here (FAC-INV-1: online + offline produce the same shape). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Protocol, runtime_checkable +from uuid import UUID + +from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt + +__all__ = [ + "FlightDto", + "FlightsApiClient", + "WaypointDto", + "WaypointObjective", + "WaypointSource", +] + + +class WaypointObjective(Enum): + """Mission-planning intent attached to a single waypoint. + + Mirrors ``suite/flights/Database/Entities/WaypointObjective.cs``. Unknown + values raise :class:`WaypointSchemaError` during parsing per FAC-INV-1. + """ + + TAKEOFF = "takeoff" + WAYPOINT = "waypoint" + LOITER = "loiter" + LANDING = "landing" + OTHER = "other" + + +class WaypointSource(Enum): + """Origin of the waypoint per the parent-suite enum.""" + + OPERATOR = "operator" + IMPORT = "import" + OTHER = "other" + + +@dataclass(frozen=True, slots=True) +class WaypointDto: + """A single ordered waypoint inside a :class:`FlightDto`. + + ``ordinal`` is the strictly-ascending sort key inside the parent flight; + parsing enforces a contiguous ``0..N-1`` sequence (FAC-INV-2). + ``alt_m`` is the WGS84 ellipsoidal height in metres. + """ + + ordinal: int + lat_deg: float + lon_deg: float + alt_m: float + objective: WaypointObjective + source: WaypointSource + + +@dataclass(frozen=True, slots=True) +class FlightDto: + """An operator-planned mission resolved from the flights service or a file. + + ``waypoints`` is non-empty and ordered by ascending ``ordinal`` + (FAC-INV-2). ``waypoints[0]`` is the takeoff origin per ADR-010 — see + :func:`takeoff_origin_from_flight`. + """ + + flight_id: UUID + name: str + waypoints: tuple[WaypointDto, ...] + + +@runtime_checkable +class FlightsApiClient(Protocol): + """Read a :class:`FlightDto` from the parent-suite flights service or a file. + + Pure read; no side effects beyond structured logging. The caller (C12 + ``CacheBuildWorkflow``) decides which source to use based on CLI flags + (``--flight-id`` vs ``--flight-file``). + """ + + def fetch_flight( + self, + *, + flight_id: UUID, + base_url: str, + auth_token: str, + timeout_s: float = 10.0, + ) -> FlightDto: + """Resolve via HTTPS against the parent-suite ``flights`` service. + + Raises: + FlightsApiUnreachableError: HTTPS timeout / 5xx after the + single allowed retry (FAC-INV-5). + FlightsApiAuthError: HTTP 401 / 403 (never retried; never logs + ``auth_token``). + FlightNotFoundError: HTTP 404 — operator gave a wrong GUID. + FlightsApiSchemaError: response body violates the DTO schema. + WaypointSchemaError: a waypoint inside the response is malformed. + """ + ... + + def load_flight_file(self, *, path: Path) -> FlightDto: + """Resolve from a JSON export on disk (offline path). + + Returns a DTO with the SAME shape as :meth:`fetch_flight` (FAC-INV-1). + """ + ... + + def bbox_from_waypoints( + self, + waypoints: tuple[WaypointDto, ...], + *, + buffer_m: float = 1000.0, + ) -> BoundingBox: + """Envelope ``waypoints`` lat/lon and inflate by ``buffer_m`` horizontal metres. + + FAC-INV-3: buffer is a horizontal-distance expansion via + ``WgsConverter`` ENU round-trip, NOT a degree-space expansion. + """ + ... + + def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt: + """Return ``waypoints[0]`` as a :class:`LatLonAlt` — no rounding (FAC-INV-4).""" + ... diff --git a/src/gps_denied_onboard/runtime_root/__init__.py b/src/gps_denied_onboard/runtime_root/__init__.py index 0032a32..4cf120e 100644 --- a/src/gps_denied_onboard/runtime_root/__init__.py +++ b/src/gps_denied_onboard/runtime_root/__init__.py @@ -24,6 +24,9 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Final, Literal, get_args from gps_denied_onboard.config import Config, load_config +from gps_denied_onboard.runtime_root.c12_factory import ( + build_flights_api_client, +) from gps_denied_onboard.runtime_root.fc_factory import ( OutboundThreadAlreadyBoundError, bind_outbound_emit_thread, @@ -77,6 +80,7 @@ __all__ = [ "bind_outbound_emit_thread", "bind_state_ingest_thread", "build_fc_adapter", + "build_flights_api_client", "build_gcs_adapter", "build_pose_estimator", "build_state_estimator", diff --git a/src/gps_denied_onboard/runtime_root/c12_factory.py b/src/gps_denied_onboard/runtime_root/c12_factory.py new file mode 100644 index 0000000..4f31ae0 --- /dev/null +++ b/src/gps_denied_onboard/runtime_root/c12_factory.py @@ -0,0 +1,44 @@ +"""Composition-root factory for C12 operator-tooling services (AZ-489). + +Currently exposes :func:`build_flights_api_client` — the +:class:`FlightsApiClient` used by C12's pre-flight cache-build workflow +(see AZ-326 / AZ-328 for downstream consumers). + +The factory is intentionally tiny: there is only one concrete strategy +(``HttpxFlightsApiClient``) and httpx already defaults to TLS verify ON +and the system trust store, so the factory's job is to assemble the +client without re-implementing those defaults. + +The richer ``OperatorToolServices`` dataclass that aggregates this +client with the rest of C12 (``CacheBuildWorkflow``, +``OperatorReLocService``, etc.) is owned by AZ-328 and intentionally +NOT created here per scope discipline. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gps_denied_onboard.components.c12_operator_tooling.flights_api import ( + FlightsApiClient, + HttpxFlightsApiClient, +) + +if TYPE_CHECKING: + from gps_denied_onboard.config import Config + +__all__ = ["build_flights_api_client"] + + +def build_flights_api_client(config: Config) -> FlightsApiClient: + """Return the operator-tier :class:`FlightsApiClient`. + + The current implementation is the production + :class:`HttpxFlightsApiClient` with httpx defaults (TLS verify ON, + system trust store). ``config`` is accepted for API parity with the + other ``build_*`` factories; the client itself does not need + composition-time configuration — the operator's base URL and auth + token are resolved per-call by the CLI layer (AZ-326). + """ + _ = config # reserved for future composition-time tuning + return HttpxFlightsApiClient() diff --git a/tests/unit/c12_operator_tooling/test_az489_flights_api_client.py b/tests/unit/c12_operator_tooling/test_az489_flights_api_client.py new file mode 100644 index 0000000..bc2f31f --- /dev/null +++ b/tests/unit/c12_operator_tooling/test_az489_flights_api_client.py @@ -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)