mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 14:21:14 +00:00
[AZ-329] [AZ-330] [AZ-523] [AZ-524] Batch 44 atomic refactor
Implements two new C12 services and rebalances the C11/C12 boundary in one atomic commit: * AZ-329 PostLandingUploadOrchestrator — gates C11 upload on the `flight_footer` FDR record's `clean_shutdown` field; 4 refusal modes; new FdrFooterReader Protocol + LocalFdrFooterReader. * AZ-330 OperatorReLocService — AC-3.4 visual-loss re-localization hint; reuses shared LatLonAlt; OperatorCommandTransport Protocol cut (E-C8 owns the future pymavlink concrete); new FDR record kind `c12.reloc.requested`; log redaction (lat/lon 5 decimals, reason 200 chars). * AZ-523 C11 internal flight-state gate removed (SRP refactor): `confirm_flight_state` / `FlightStateSignal` use / `FlightStateNotOnGroundError` deleted from C11; TileUploader contract bumped to v2.0.0 (frozen) with migration note; AZ-317 superseded. * AZ-524 Package rename `c12_operator_tooling` → `c12_operator_orchestrator` across source, tests, pyproject, CMake, Dockerfile, compose, CI, runtime-root services class (`OperatorOrchestratorServices`) + factory function (`build_operator_orchestrator`), logger namespaces, config slug, docs, and the E-C12 epic title. Tests: 1543 passed, 80 skipped (all environment gates). Targeted AC suite (AZ-329 + AZ-330 + FdrFooterReader): 37 passed. Cold-start NFR-perf still ≤ 500 ms p99. Tracker: AZ-317 → Done (superseded); AZ-319 v2.0.0 contract bump comment; AZ-329/AZ-330 → In Testing; AZ-253 epic renamed; AZ-523 + AZ-524 created and closed as audit-trail tickets. See `_docs/03_implementation/batch_44_cycle1_report.md`. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,297 +0,0 @@
|
||||
"""AZ-317 ``FlightStateGate`` unit tests.
|
||||
|
||||
Covers all eight acceptance criteria + NFRs from
|
||||
``_docs/02_tasks/done/AZ-317_c11_flight_state_gate.md`` (after the
|
||||
batch-38 archive). Uses a hand-rolled fake :class:`FlightStateSource`
|
||||
and a list-backed log handler so assertions stay close to the
|
||||
captured records.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
FlightStateGate,
|
||||
FlightStateNotOnGroundError,
|
||||
FlightStateSignal,
|
||||
FlightStateSource,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSource:
|
||||
"""Hand-rolled :class:`FlightStateSource` returning a fixed signal.
|
||||
|
||||
Spies on every ``current_flight_state`` call so AC-8 can assert
|
||||
the gate calls the source exactly once per ``confirm_on_ground``.
|
||||
"""
|
||||
|
||||
def __init__(self, signal: FlightStateSignal) -> None:
|
||||
self._signal = signal
|
||||
self.call_count = 0
|
||||
|
||||
def current_flight_state(self) -> FlightStateSignal:
|
||||
self.call_count += 1
|
||||
return self._signal
|
||||
|
||||
|
||||
class _RaisingSource:
|
||||
""":class:`FlightStateSource` whose ``current_flight_state`` raises."""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
self._exc = exc
|
||||
self.call_count = 0
|
||||
|
||||
def current_flight_state(self) -> FlightStateSignal:
|
||||
self.call_count += 1
|
||||
raise self._exc
|
||||
|
||||
|
||||
class _PartialFake:
|
||||
"""Type stub WITHOUT ``current_flight_state`` for AC-6 negative case."""
|
||||
|
||||
def something_else(self) -> str:
|
||||
return "noop"
|
||||
|
||||
|
||||
def _build_gate(
|
||||
*,
|
||||
source: FlightStateSource,
|
||||
) -> tuple[FlightStateGate, list[logging.LogRecord]]:
|
||||
records: list[logging.LogRecord] = []
|
||||
|
||||
class _ListHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
records.append(record)
|
||||
|
||||
logger = logging.getLogger(f"test_az317_{id(records)}")
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(_ListHandler())
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
|
||||
return FlightStateGate(source=source, logger=logger), records
|
||||
|
||||
|
||||
def _kinds(records: list[logging.LogRecord]) -> list[str]:
|
||||
return [getattr(r, "kind", None) for r in records]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: ON_GROUND passes
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_on_ground_returns_signal_and_emits_info_log() -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(FlightStateSignal.ON_GROUND)
|
||||
gate, records = _build_gate(source=source)
|
||||
|
||||
# Act
|
||||
result = gate.confirm_on_ground()
|
||||
|
||||
# Assert
|
||||
assert result is FlightStateSignal.ON_GROUND
|
||||
assert _kinds(records) == ["c11.upload.flight_state_confirmed"]
|
||||
assert records[0].levelname == "INFO"
|
||||
assert source.call_count == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: IN_FLIGHT raises
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_in_flight_raises_with_observed_and_error_log() -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
|
||||
gate, records = _build_gate(source=source)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
|
||||
gate.confirm_on_ground()
|
||||
|
||||
assert excinfo.value.observed is FlightStateSignal.IN_FLIGHT
|
||||
assert "IN_FLIGHT" in str(excinfo.value)
|
||||
assert _kinds(records) == ["c11.upload.refused.flight_state"]
|
||||
assert records[0].levelname == "ERROR"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: UNKNOWN raises (fail-closed)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_unknown_raises_fail_closed() -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(FlightStateSignal.UNKNOWN)
|
||||
gate, records = _build_gate(source=source)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
|
||||
gate.confirm_on_ground()
|
||||
|
||||
assert excinfo.value.observed is FlightStateSignal.UNKNOWN
|
||||
assert _kinds(records) == ["c11.upload.refused.flight_state"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: TAKING_OFF and LANDING raise
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transition_signal",
|
||||
[FlightStateSignal.TAKING_OFF, FlightStateSignal.LANDING],
|
||||
)
|
||||
def test_ac4_transition_states_raise(
|
||||
transition_signal: FlightStateSignal,
|
||||
) -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(transition_signal)
|
||||
gate, records = _build_gate(source=source)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
|
||||
gate.confirm_on_ground()
|
||||
|
||||
assert excinfo.value.observed is transition_signal
|
||||
assert _kinds(records) == ["c11.upload.refused.flight_state"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: source exception → UNKNOWN with __cause__ chained
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac5_source_exception_maps_to_unknown_and_preserves_cause() -> None:
|
||||
# Arrange
|
||||
original = RuntimeError("FC disconnected")
|
||||
source = _RaisingSource(original)
|
||||
gate, records = _build_gate(source=source)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
|
||||
gate.confirm_on_ground()
|
||||
|
||||
assert excinfo.value.observed is FlightStateSignal.UNKNOWN
|
||||
assert excinfo.value.__cause__ is original
|
||||
assert _kinds(records) == ["c11.upload.refused.flight_state"]
|
||||
assert records[0].levelname == "ERROR"
|
||||
assert "FC disconnected" in records[0].kv["source_error"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: FlightStateSource Protocol is conformance-checkable
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac6_protocol_isinstance_check_distinguishes_conforming_from_partial() -> None:
|
||||
# Arrange
|
||||
conforming = _FakeSource(FlightStateSignal.ON_GROUND)
|
||||
non_conforming = _PartialFake()
|
||||
|
||||
# Assert
|
||||
assert isinstance(conforming, FlightStateSource)
|
||||
assert not isinstance(non_conforming, FlightStateSource)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: Error carries diagnostic fields
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac7_error_carries_observed_and_observed_at_with_message_format() -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
|
||||
gate, _ = _build_gate(source=source)
|
||||
|
||||
# Act
|
||||
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
|
||||
gate.confirm_on_ground()
|
||||
|
||||
# Assert
|
||||
assert excinfo.value.observed is FlightStateSignal.IN_FLIGHT
|
||||
assert isinstance(excinfo.value.observed_at, datetime)
|
||||
assert excinfo.value.observed_at.tzinfo == timezone.utc
|
||||
assert excinfo.value.observed_at.microsecond == 0
|
||||
assert str(excinfo.value).startswith("Upload refused: flight state is ")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: Gate calls source exactly once
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac8_gate_calls_source_exactly_once_no_retry() -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
|
||||
gate, _ = _build_gate(source=source)
|
||||
|
||||
# Act
|
||||
with pytest.raises(FlightStateNotOnGroundError):
|
||||
gate.confirm_on_ground()
|
||||
|
||||
# Assert
|
||||
assert source.call_count == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# NFR-perf: confirm_on_ground microbench p99 ≤ 1 ms
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nfr_perf_microbench_under_one_ms_p99() -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(FlightStateSignal.ON_GROUND)
|
||||
gate, _ = _build_gate(source=source)
|
||||
iterations = 5_000
|
||||
|
||||
# Act
|
||||
samples_ns: list[int] = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter_ns()
|
||||
gate.confirm_on_ground()
|
||||
samples_ns.append(time.perf_counter_ns() - start)
|
||||
|
||||
# Assert
|
||||
samples_ns.sort()
|
||||
p99_ns = samples_ns[int(iterations * 0.99) - 1]
|
||||
assert p99_ns < 1_000_000, (
|
||||
f"p99 latency {p99_ns} ns exceeds 1 ms (1_000_000 ns) NFR budget"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# NFR-reliability-fail-closed: every non-ON_GROUND state raises
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"non_on_ground_signal",
|
||||
[
|
||||
FlightStateSignal.IN_FLIGHT,
|
||||
FlightStateSignal.TAKING_OFF,
|
||||
FlightStateSignal.LANDING,
|
||||
FlightStateSignal.UNKNOWN,
|
||||
],
|
||||
)
|
||||
def test_nfr_reliability_fail_closed_matrix_complete(
|
||||
non_on_ground_signal: FlightStateSignal,
|
||||
) -> None:
|
||||
# Arrange
|
||||
source = _FakeSource(non_on_ground_signal)
|
||||
gate, _ = _build_gate(source=source)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError):
|
||||
gate.confirm_on_ground()
|
||||
@@ -26,8 +26,6 @@ import pytest
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
C11RetryConfig,
|
||||
FlightStateNotOnGroundError,
|
||||
FlightStateSignal,
|
||||
IdempotentRetryTileUploader,
|
||||
IngestStatus,
|
||||
PerTileStatus,
|
||||
@@ -76,7 +74,6 @@ class _ScriptedInner:
|
||||
self.raises = list(raise_on_call or [])
|
||||
self.calls: list[UploadRequest] = []
|
||||
self.enumerate_calls: list[Any] = []
|
||||
self.confirm_calls: int = 0
|
||||
|
||||
def upload_pending_tiles(self, request: UploadRequest) -> UploadBatchReport:
|
||||
self.calls.append(request)
|
||||
@@ -94,10 +91,6 @@ class _ScriptedInner:
|
||||
self.enumerate_calls.append(flight_id)
|
||||
return [{"sentinel": True, "flight_id": flight_id}]
|
||||
|
||||
def confirm_flight_state(self) -> FlightStateSignal:
|
||||
self.confirm_calls += 1
|
||||
return FlightStateSignal.ON_GROUND
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMetadataStore:
|
||||
@@ -388,39 +381,11 @@ def test_ac11_enumerate_pending_passes_through() -> None:
|
||||
assert out == [{"sentinel": True, "flight_id": fid}]
|
||||
|
||||
|
||||
def test_ac11_confirm_flight_state_passes_through() -> None:
|
||||
# Arrange
|
||||
inner = _ScriptedInner(reports=[_success(0)])
|
||||
(decorator, _logs, _store, _clk, _fdr) = _build_decorator(inner=inner)
|
||||
|
||||
# Act
|
||||
state = decorator.confirm_flight_state()
|
||||
|
||||
# Assert
|
||||
assert state == FlightStateSignal.ON_GROUND
|
||||
assert inner.confirm_calls == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-12 — inner exception propagates without retry
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac12_flight_state_not_on_ground_propagates_without_retry() -> None:
|
||||
# Arrange
|
||||
from datetime import datetime, timezone
|
||||
|
||||
err = FlightStateNotOnGroundError(FlightStateSignal.IN_FLIGHT, datetime.now(timezone.utc))
|
||||
inner = _ScriptedInner(raise_on_call=[err])
|
||||
(decorator, _logs, _store, clk, _fdr) = _build_decorator(inner=inner)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError):
|
||||
decorator.upload_pending_tiles(_request())
|
||||
assert clk.sleep_calls == []
|
||||
assert len(inner.calls) == 1
|
||||
|
||||
|
||||
def test_ac12_satellite_provider_error_propagates_without_retry() -> None:
|
||||
# Arrange
|
||||
inner = _ScriptedInner(raise_on_call=[SatelliteProviderError("boom")])
|
||||
@@ -493,7 +458,6 @@ def test_ac10_factory_returns_decorated_uploader_by_default() -> None:
|
||||
http_client=_httpx.Client(transport=transport),
|
||||
tile_store=object(),
|
||||
tile_metadata_store=object(),
|
||||
flight_state_gate=object(), # type: ignore[arg-type]
|
||||
key_manager=object(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
@@ -516,7 +480,6 @@ def test_ac10_factory_bypasses_decorator_when_flag_set() -> None:
|
||||
http_client=_httpx.Client(transport=transport),
|
||||
tile_store=object(),
|
||||
tile_metadata_store=object(),
|
||||
flight_state_gate=object(), # type: ignore[arg-type]
|
||||
key_manager=object(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@@ -33,17 +33,12 @@ class _NullSleep:
|
||||
return None
|
||||
|
||||
|
||||
class _PartialFakeMissingConfirm:
|
||||
"""Conformance counterexample: missing ``confirm_flight_state``."""
|
||||
class _PartialFakeMissingEnumerate:
|
||||
"""Conformance counterexample: missing ``enumerate_pending_tiles``."""
|
||||
|
||||
def upload_pending_tiles(self, request: object) -> object: # noqa: ARG002
|
||||
return None
|
||||
|
||||
def enumerate_pending_tiles(
|
||||
self, flight_id: object | None = None
|
||||
) -> list[object]: # noqa: ARG002
|
||||
return []
|
||||
|
||||
|
||||
class _PartialDownloaderMissingEnumerate:
|
||||
"""Conformance counterexample: missing ``enumerate_remote_coverage``."""
|
||||
@@ -67,7 +62,6 @@ def test_ac12_concrete_uploader_satisfies_protocol() -> None:
|
||||
http_client=httpx.Client(transport=transport),
|
||||
tile_store=object(), # type: ignore[arg-type]
|
||||
tile_metadata_store=object(), # type: ignore[arg-type]
|
||||
flight_state_gate=object(), # type: ignore[arg-type]
|
||||
key_manager=object(), # type: ignore[arg-type]
|
||||
fdr_client=FakeFdrSink(_PRODUCER_ID), # type: ignore[arg-type]
|
||||
logger=logging.getLogger("test_az319_conformance"),
|
||||
@@ -81,7 +75,7 @@ def test_ac12_concrete_uploader_satisfies_protocol() -> None:
|
||||
|
||||
def test_ac12_partial_fake_is_not_protocol_conformant() -> None:
|
||||
# Assert
|
||||
assert not isinstance(_PartialFakeMissingConfirm(), TileUploader)
|
||||
assert not isinstance(_PartialFakeMissingEnumerate(), TileUploader)
|
||||
|
||||
|
||||
def test_ac10_concrete_downloader_satisfies_protocol() -> None:
|
||||
@@ -129,7 +123,6 @@ def test_ac9_idempotent_retry_decorator_satisfies_uploader_protocol() -> None:
|
||||
http_client=httpx.Client(transport=transport),
|
||||
tile_store=object(), # type: ignore[arg-type]
|
||||
tile_metadata_store=object(), # type: ignore[arg-type]
|
||||
flight_state_gate=object(), # type: ignore[arg-type]
|
||||
key_manager=object(), # type: ignore[arg-type]
|
||||
fdr_client=FakeFdrSink(_PRODUCER_ID), # type: ignore[arg-type]
|
||||
logger=logging.getLogger("test_az320_inner"),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""AZ-319 ``HttpTileUploader`` unit tests.
|
||||
|
||||
Covers AC-1 .. AC-14 and the upload-throughput NFR from
|
||||
``_docs/02_tasks/todo/AZ-319_c11_tile_uploader.md``.
|
||||
Covers AC-1, AC-3 .. AC-14 and the upload-throughput NFR from
|
||||
``_docs/02_tasks/done/AZ-319_c11_tile_uploader.md``. AC-2 (the legacy
|
||||
ON_GROUND gate) was removed in batch 44 — gating is now C12's
|
||||
``PostLandingUploadOrchestrator`` responsibility.
|
||||
|
||||
Uses :class:`httpx.MockTransport` for deterministic HTTP responses,
|
||||
:class:`FakeFdrSink` for FDR capture, a list-backed ``logging.Handler``
|
||||
for log capture, and stub C6 stores / gate / key manager so this
|
||||
suite never drags in AZ-303 / AZ-305 / AZ-317 / AZ-318 internals.
|
||||
for log capture, and stub C6 stores / key manager so this suite never
|
||||
drags in AZ-303 / AZ-305 / AZ-318 internals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,8 +27,6 @@ import pytest
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
C11Config,
|
||||
FlightStateNotOnGroundError,
|
||||
FlightStateSignal,
|
||||
HttpTileUploader,
|
||||
IngestStatus,
|
||||
PerFlightKeyManager,
|
||||
@@ -37,9 +37,6 @@ from gps_denied_onboard.components.c11_tile_manager import (
|
||||
UploadRequest,
|
||||
canonical_payload_bytes,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.flight_state_gate import (
|
||||
FlightStateGate,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client import FdrRecord
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
|
||||
@@ -125,25 +122,6 @@ class _FakeMetadataStore:
|
||||
self.mark_calls.append((tile_id, uploaded_at))
|
||||
|
||||
|
||||
class _StubGate:
|
||||
"""Stand-in for AZ-317 ``FlightStateGate``."""
|
||||
|
||||
def __init__(
|
||||
self, signal: FlightStateSignal = FlightStateSignal.ON_GROUND
|
||||
) -> None:
|
||||
self._signal = signal
|
||||
self.confirm_calls: int = 0
|
||||
|
||||
def confirm_on_ground(self) -> FlightStateSignal:
|
||||
self.confirm_calls += 1
|
||||
if self._signal != FlightStateSignal.ON_GROUND:
|
||||
raise FlightStateNotOnGroundError(
|
||||
self._signal,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
return self._signal
|
||||
|
||||
|
||||
class _StubKeyManager:
|
||||
"""Stand-in for AZ-318 ``PerFlightKeyManager``.
|
||||
|
||||
@@ -222,7 +200,6 @@ def _build_uploader(
|
||||
transport: httpx.MockTransport,
|
||||
pending: list[_FakeTile] | None = None,
|
||||
blobs: dict[str, bytes] | None = None,
|
||||
gate_signal: FlightStateSignal = FlightStateSignal.ON_GROUND,
|
||||
fingerprint_hex: str = "0123456789abcdef",
|
||||
config: C11Config | None = None,
|
||||
sleep_recorder: list[float] | None = None,
|
||||
@@ -230,7 +207,6 @@ def _build_uploader(
|
||||
HttpTileUploader,
|
||||
FakeFdrSink,
|
||||
list[logging.LogRecord],
|
||||
_StubGate,
|
||||
_StubKeyManager,
|
||||
_FakeTileStore,
|
||||
_FakeMetadataStore,
|
||||
@@ -249,7 +225,6 @@ def _build_uploader(
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
|
||||
gate = _StubGate(signal=gate_signal)
|
||||
key_manager = _StubKeyManager(fingerprint_hex=fingerprint_hex)
|
||||
tile_store = _FakeTileStore(blobs=blobs)
|
||||
metadata_store = _FakeMetadataStore(pending=pending)
|
||||
@@ -272,14 +247,13 @@ def _build_uploader(
|
||||
http_client=client,
|
||||
tile_store=tile_store,
|
||||
tile_metadata_store=metadata_store,
|
||||
flight_state_gate=gate, # type: ignore[arg-type]
|
||||
key_manager=key_manager, # type: ignore[arg-type]
|
||||
fdr_client=fdr, # type: ignore[arg-type]
|
||||
logger=logger,
|
||||
config=cfg,
|
||||
sleep=_sleep,
|
||||
)
|
||||
return uploader, fdr, log_records, gate, key_manager, tile_store, metadata_store, sleeps
|
||||
return uploader, fdr, log_records, key_manager, tile_store, metadata_store, sleeps
|
||||
|
||||
|
||||
def _make_request(*, batch_size: int = 10, flight_id: UUID | None = None) -> UploadRequest:
|
||||
@@ -361,7 +335,6 @@ def test_ac1_50_tile_happy_path_marks_all_uploaded() -> None:
|
||||
uploader,
|
||||
fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
metadata_store,
|
||||
@@ -385,48 +358,6 @@ def test_ac1_50_tile_happy_path_marks_all_uploaded() -> None:
|
||||
assert key_manager.end_calls == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: gate blocks before any read or POST
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_gate_blocks_before_any_read_or_post() -> None:
|
||||
# Arrange
|
||||
pending = [_make_tile()]
|
||||
posted: list[httpx.Request] = []
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
posted.append(request)
|
||||
return httpx.Response(202, json={"batch_uuid": str(uuid4()), "per_tile_status": []})
|
||||
|
||||
transport = httpx.MockTransport(_handler)
|
||||
(
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
gate,
|
||||
key_manager,
|
||||
tile_store,
|
||||
metadata_store,
|
||||
_sleeps,
|
||||
) = _build_uploader(
|
||||
transport=transport,
|
||||
pending=pending,
|
||||
gate_signal=FlightStateSignal.IN_FLIGHT,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightStateNotOnGroundError):
|
||||
uploader.upload_pending_tiles(_make_request())
|
||||
|
||||
assert gate.confirm_calls == 1
|
||||
assert metadata_store.pending_calls == 0
|
||||
assert tile_store.read_calls == []
|
||||
assert key_manager.start_calls == []
|
||||
assert key_manager.end_calls == 0
|
||||
assert posted == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: signature rejection — record + skip mark_uploaded; outcome=partial
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -457,7 +388,6 @@ def test_ac3_signature_rejection_records_and_keeps_pending() -> None:
|
||||
uploader,
|
||||
fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
metadata_store,
|
||||
@@ -504,7 +434,6 @@ def test_ac4_duplicate_and_superseded_are_success() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
_key_manager,
|
||||
_tile_store,
|
||||
metadata_store,
|
||||
@@ -536,7 +465,6 @@ def test_ac5_signing_key_zeroised_on_success() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -570,7 +498,6 @@ def test_ac6_signing_key_zeroised_on_failure() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
metadata_store,
|
||||
@@ -605,7 +532,6 @@ def test_ac7_public_key_fdr_precedes_tile_fdr() -> None:
|
||||
uploader,
|
||||
fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
_key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -661,7 +587,6 @@ def test_ac8_429_honours_retry_after_seconds() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
_key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -697,7 +622,6 @@ def test_ac9_persistent_5xx_raises_satellite_provider_error() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -731,7 +655,6 @@ def test_ac10_401_fails_fast_no_retry() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
log_records,
|
||||
_gate,
|
||||
_key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -766,7 +689,6 @@ def test_ac11_empty_pending_set_is_success_no_posts() -> None:
|
||||
uploader,
|
||||
fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -868,7 +790,6 @@ def test_ac14_partial_success_batch_does_not_raise() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
_key_manager,
|
||||
_tile_store,
|
||||
metadata_store,
|
||||
@@ -910,7 +831,6 @@ def test_429_budget_exhaustion_raises_rate_limited_error() -> None:
|
||||
uploader,
|
||||
_fdr,
|
||||
_logs,
|
||||
_gate,
|
||||
key_manager,
|
||||
_tile_store,
|
||||
_metadata_store,
|
||||
@@ -951,7 +871,7 @@ def test_nfr_throughput_1000_tiles_under_budget() -> None:
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(_handler)
|
||||
(uploader, _fdr, _logs, _gate, _km, _ts, _ms, _sleeps) = _build_uploader(
|
||||
(uploader, _fdr, _logs, _km, _ts, _ms, _sleeps) = _build_uploader(
|
||||
transport=transport, pending=pending
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api import (
|
||||
EmptyWaypointsError,
|
||||
FlightDto,
|
||||
FlightFileNotFoundError,
|
||||
+7
-7
@@ -18,7 +18,7 @@ from uuid import UUID
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
BuildCacheOrchestrator,
|
||||
BuildCacheOutcome,
|
||||
BuildCacheRequest,
|
||||
@@ -51,20 +51,20 @@ from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
WaypointObjective,
|
||||
WaypointSource,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.file_lock import LockTimeout
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.file_lock import LockTimeout
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
FlightsApiClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
|
||||
RemoteBuildRequest,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
RemoteCommandResult,
|
||||
SshSession,
|
||||
SshSessionFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_downloader_cut import (
|
||||
TileDownloaderCut,
|
||||
)
|
||||
|
||||
@@ -948,7 +948,7 @@ class TestCompositionRootSmoke:
|
||||
# Reasonable smoke: real CompanionBringup with a fake SSH factory
|
||||
# constructs without raising; the orchestrator pulls the same
|
||||
# instance via the services dataclass.
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
|
||||
RemoteSidecarVerifier,
|
||||
)
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ from uuid import UUID
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
EXIT_BUILD_FAILURE,
|
||||
EXIT_DOWNLOAD_FAILURE,
|
||||
EXIT_EMPTY_WAYPOINTS,
|
||||
@@ -42,7 +42,7 @@ from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
FlightFromFile,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.cli import app
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.cli import app
|
||||
|
||||
_FLIGHT_ID = UUID("00000000-0000-0000-0000-000000000001")
|
||||
_API_KEY = "super-secret-api-key"
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
"""AZ-326 AC-8 — `operator-tool` console script is installed and runnable."""
|
||||
"""AZ-326 AC-8 — `operator-orchestrator` console script is installed and runnable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,35 +12,35 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def operator_tool_binary() -> str:
|
||||
def operator_orchestrator_binary() -> str:
|
||||
# Prefer PATH (mimics operator install). Fall back to the active Python
|
||||
# interpreter's bin directory so the test still runs in an unactivated
|
||||
# venv (`.venv/bin/pytest ...`), which is the common CI invocation.
|
||||
candidate = shutil.which("operator-tool")
|
||||
candidate = shutil.which("operator-orchestrator")
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
venv_bin = Path(sys.executable).parent / "operator-tool"
|
||||
venv_bin = Path(sys.executable).parent / "operator-orchestrator"
|
||||
if venv_bin.exists():
|
||||
return str(venv_bin)
|
||||
pytest.skip("operator-tool console script not on PATH or in venv bin")
|
||||
pytest.skip("operator-orchestrator console script not on PATH or in venv bin")
|
||||
|
||||
|
||||
class TestConsoleScript:
|
||||
def test_help_exits_zero(self, operator_tool_binary: str) -> None:
|
||||
def test_help_exits_zero(self, operator_orchestrator_binary: str) -> None:
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
[operator_tool_binary, "--help"],
|
||||
[operator_orchestrator_binary, "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
# Assert
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "operator-tool" in result.stdout
|
||||
assert "operator-orchestrator" in result.stdout
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_cold_start_under_500ms_p99(self, operator_tool_binary: str) -> None:
|
||||
"""NFR-perf-cold-start — `operator-tool --help` ≤ 500 ms p99 over 11 runs.
|
||||
def test_cold_start_under_500ms_p99(self, operator_orchestrator_binary: str) -> None:
|
||||
"""NFR-perf-cold-start — `operator-orchestrator --help` ≤ 500 ms p99 over 11 runs.
|
||||
|
||||
Methodology: 11 cold-start subprocess runs, drop the single
|
||||
worst sample (system noise: OS context switch, disk cache
|
||||
@@ -55,7 +55,7 @@ class TestConsoleScript:
|
||||
for _ in range(11):
|
||||
start = time.monotonic()
|
||||
subprocess.run(
|
||||
[operator_tool_binary, "--help"],
|
||||
[operator_orchestrator_binary, "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
+6
-6
@@ -14,10 +14,10 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
EXIT_OK,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.cli import app
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.cli import app
|
||||
|
||||
_EXPECTED_SUBCOMMANDS = {
|
||||
"download",
|
||||
@@ -42,7 +42,7 @@ def isolated_log(tmp_path: Path) -> Path:
|
||||
|
||||
|
||||
class TestSubcommandRegistration:
|
||||
"""AC-1 — `operator-tool --help` lists exactly the six subcommands."""
|
||||
"""AC-1 — `operator-orchestrator --help` lists exactly the six subcommands."""
|
||||
|
||||
def test_top_level_help_lists_all_six_subcommands(self, runner: CliRunner) -> None:
|
||||
# Act
|
||||
@@ -92,11 +92,11 @@ class TestSuccessfulSetSectorAcTwo:
|
||||
config_obj = SimpleNamespace()
|
||||
# Inject a config via the --log-path override + per-test sector store
|
||||
# by calling the underlying Click command directly with a custom obj.
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
C12Config,
|
||||
HostKeyPolicy,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12CompanionConfig,
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ class TestStructuredLoggingShapeAcSeven:
|
||||
) -> None:
|
||||
# Arrange
|
||||
store_path = tmp_path / "sector.json"
|
||||
from gps_denied_onboard.components.c12_operator_tooling import C12Config
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import C12Config
|
||||
|
||||
# Act
|
||||
result = runner.invoke(
|
||||
+3
-3
@@ -9,7 +9,7 @@ from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
C12CompanionConfig,
|
||||
CompanionAddress,
|
||||
CompanionBringup,
|
||||
@@ -19,10 +19,10 @@ from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
HostKeyPolicy,
|
||||
ReadinessOutcome,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
|
||||
RemoteSidecarResult,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
RemoteCommandResult,
|
||||
SshSession,
|
||||
SshSessionFactory,
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import exit_codes
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import exit_codes
|
||||
|
||||
|
||||
class TestExitCodes:
|
||||
@@ -0,0 +1,430 @@
|
||||
"""AZ-329 LocalFdrFooterReader unit + integration tests.
|
||||
|
||||
Covers:
|
||||
* AC-6 — newest-segment-first short-circuit (the reader opens only the
|
||||
newest segment when the footer lives there).
|
||||
* AC-9 — real FDR fixture C12-IT-03(a): clean-shutdown footer present →
|
||||
the reader returns the parsed :class:`FlightFooterRecord`.
|
||||
* AC-10 — real FDR fixture C12-IT-03(b): no-footer truncation → the
|
||||
reader returns ``None`` and the orchestrator refuses with
|
||||
``footer_missing`` (integration via a tiny composition of orchestrator
|
||||
+ reader on the truncated fixture).
|
||||
* Frame-corruption paths (truncated length prefix, truncated body,
|
||||
invalid JSON, wrong-flight UUID, payload schema mismatch) →
|
||||
:class:`FdrUnreadableError`.
|
||||
|
||||
The fixtures are generated in-test using the same length-prefixed
|
||||
serialisation the C13 writer uses (``struct.Struct('<I')`` +
|
||||
``fdr_client.records.serialise``). Driving the full
|
||||
:class:`FileFdrWriter` lifecycle is unnecessary — the on-disk frame
|
||||
layout is the contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
FlightFooterRecord,
|
||||
PostLandingUploadRequest,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12PostLandingConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
FdrUnreadableError,
|
||||
FlightStateNotConfirmedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader import (
|
||||
LocalFdrFooterReader,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
|
||||
PostLandingUploadOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord, serialise
|
||||
|
||||
_LENGTH_PREFIX = struct.Struct("<I")
|
||||
|
||||
|
||||
def _frame_payload(record: FdrRecord) -> bytes:
|
||||
body = serialise(record)
|
||||
return _LENGTH_PREFIX.pack(len(body)) + body
|
||||
|
||||
|
||||
def _make_record(*, kind: str, payload: dict[str, Any]) -> FdrRecord:
|
||||
return FdrRecord(
|
||||
schema_version=1,
|
||||
ts="2026-05-13T12:00:00+00:00",
|
||||
producer_id="test.producer",
|
||||
kind=kind,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _make_footer_record(*, flight_id: UUID, clean_shutdown: bool) -> FdrRecord:
|
||||
return _make_record(
|
||||
kind="flight_footer",
|
||||
payload={
|
||||
"flight_id": str(flight_id),
|
||||
"flight_ended_at_iso": "2026-05-13T12:00:00+00:00",
|
||||
"flight_ended_at_monotonic_ns": 1234567890,
|
||||
"records_written": 9876,
|
||||
"records_dropped_overrun": 0 if clean_shutdown else 5,
|
||||
"bytes_written": 1024 * 1024,
|
||||
"rollover_count": 0,
|
||||
"clean_shutdown": clean_shutdown,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _write_segment(
|
||||
flight_dir: Path, segment_index: int, records: list[FdrRecord]
|
||||
) -> Path:
|
||||
path = flight_dir / f"segment-{segment_index:04d}.fdr"
|
||||
with open(path, "wb") as fh:
|
||||
for record in records:
|
||||
fh.write(_frame_payload(record))
|
||||
return path
|
||||
|
||||
|
||||
def _setup_flight_dir(tmp_path: Path, flight_id: UUID) -> tuple[Path, Path]:
|
||||
fdr_root = tmp_path / "fdr"
|
||||
fdr_root.mkdir()
|
||||
flight_dir = fdr_root / str(flight_id)
|
||||
flight_dir.mkdir()
|
||||
return fdr_root, flight_dir
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6 — newest-segment-first short-circuit
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac6_reader_short_circuits_on_newest_segment(tmp_path, monkeypatch) -> None:
|
||||
# Arrange — three segments; footer lives in the newest (index 2).
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
_write_segment(
|
||||
flight_dir, 0, [_make_record(kind="log", payload={"msg": "early"})]
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir, 1, [_make_record(kind="log", payload={"msg": "middle"})]
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir,
|
||||
2,
|
||||
[_make_footer_record(flight_id=flight_id, clean_shutdown=True)],
|
||||
)
|
||||
|
||||
opened_paths: list[str] = []
|
||||
real_open = open
|
||||
|
||||
def _tracking_open(file, *args, **kwargs):
|
||||
if str(file).endswith(".fdr"):
|
||||
opened_paths.append(str(file))
|
||||
return real_open(file, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader.open",
|
||||
_tracking_open,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act
|
||||
footer = reader.read_footer(flight_id)
|
||||
|
||||
# Assert
|
||||
assert footer is not None
|
||||
assert footer.flight_id == flight_id
|
||||
assert footer.clean_shutdown is True
|
||||
assert len(opened_paths) == 1
|
||||
assert opened_paths[0].endswith("segment-0002.fdr")
|
||||
|
||||
|
||||
def test_ac6_reader_walks_older_segments_when_newest_has_no_footer(tmp_path) -> None:
|
||||
# Arrange — footer lives in segment 0 (oldest); newer segments have no footer.
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
_write_segment(
|
||||
flight_dir,
|
||||
0,
|
||||
[_make_footer_record(flight_id=flight_id, clean_shutdown=True)],
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir, 1, [_make_record(kind="log", payload={"msg": "stray"})]
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir, 2, [_make_record(kind="log", payload={"msg": "stray2"})]
|
||||
)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act
|
||||
footer = reader.read_footer(flight_id)
|
||||
|
||||
# Assert
|
||||
assert footer is not None
|
||||
assert footer.clean_shutdown is True
|
||||
|
||||
|
||||
def test_reader_returns_none_when_no_footer_anywhere(tmp_path) -> None:
|
||||
# Arrange
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
_write_segment(
|
||||
flight_dir, 0, [_make_record(kind="log", payload={"msg": "only-log"})]
|
||||
)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act
|
||||
footer = reader.read_footer(flight_id)
|
||||
|
||||
# Assert
|
||||
assert footer is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Parse / framing corruption → FdrUnreadableError
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reader_raises_on_truncated_length_prefix(tmp_path) -> None:
|
||||
# Arrange — segment file ends with a single byte (not a complete prefix).
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
path = flight_dir / "segment-0000.fdr"
|
||||
path.write_bytes(b"\x01")
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FdrUnreadableError, match="truncated length prefix"):
|
||||
reader.read_footer(flight_id)
|
||||
|
||||
|
||||
def test_reader_raises_on_truncated_body(tmp_path) -> None:
|
||||
# Arrange — length prefix claims 100 bytes; only 10 are present.
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
path = flight_dir / "segment-0000.fdr"
|
||||
path.write_bytes(_LENGTH_PREFIX.pack(100) + b"x" * 10)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FdrUnreadableError, match="truncated record body"):
|
||||
reader.read_footer(flight_id)
|
||||
|
||||
|
||||
def test_reader_raises_on_invalid_json_body(tmp_path) -> None:
|
||||
# Arrange — length matches but JSON is garbage.
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
body = b"this is not json"
|
||||
path = flight_dir / "segment-0000.fdr"
|
||||
path.write_bytes(_LENGTH_PREFIX.pack(len(body)) + body)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FdrUnreadableError, match="failed to parse record"):
|
||||
reader.read_footer(flight_id)
|
||||
|
||||
|
||||
def test_reader_raises_when_footer_flight_id_mismatches(tmp_path) -> None:
|
||||
# Arrange — footer carries a different flight_id than the requested one.
|
||||
requested = uuid4()
|
||||
other = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, requested)
|
||||
_write_segment(
|
||||
flight_dir, 0, [_make_footer_record(flight_id=other, clean_shutdown=True)]
|
||||
)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FdrUnreadableError, match="flight_footer.flight_id mismatch"):
|
||||
reader.read_footer(requested)
|
||||
|
||||
|
||||
def test_reader_raises_when_footer_payload_misses_required_field(tmp_path) -> None:
|
||||
# Arrange — footer payload missing `clean_shutdown`; build the bytes
|
||||
# by hand so we bypass the serialise validator (which would not catch
|
||||
# this because `flight_footer` allows known fields without requiring
|
||||
# all of them per AZ-272's forward-compat policy).
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
bad_payload = {
|
||||
"flight_id": str(flight_id),
|
||||
"flight_ended_at_iso": "2026-05-13T12:00:00+00:00",
|
||||
"flight_ended_at_monotonic_ns": 0,
|
||||
"records_written": 1,
|
||||
"records_dropped_overrun": 0,
|
||||
"bytes_written": 0,
|
||||
"rollover_count": 0,
|
||||
# clean_shutdown intentionally omitted
|
||||
}
|
||||
envelope = {
|
||||
"schema_version": 1,
|
||||
"ts": "2026-05-13T12:00:00+00:00",
|
||||
"producer_id": "test.producer",
|
||||
"kind": "flight_footer",
|
||||
"payload": bad_payload,
|
||||
}
|
||||
body = orjson.dumps(envelope)
|
||||
path = flight_dir / "segment-0000.fdr"
|
||||
path.write_bytes(_LENGTH_PREFIX.pack(len(body)) + body)
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FdrUnreadableError, match="flight_footer payload schema violation"):
|
||||
reader.read_footer(flight_id)
|
||||
|
||||
|
||||
def test_reader_ignores_non_segment_files_in_flight_dir(tmp_path) -> None:
|
||||
# Arrange — directory contains a stray `.log` file alongside the
|
||||
# segment with the footer.
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
_write_segment(
|
||||
flight_dir,
|
||||
0,
|
||||
[_make_footer_record(flight_id=flight_id, clean_shutdown=True)],
|
||||
)
|
||||
(flight_dir / "operator-notes.log").write_text("ignore me")
|
||||
(flight_dir / "segment-bad.txt").write_text("ignore me too")
|
||||
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
# Act
|
||||
footer = reader.read_footer(flight_id)
|
||||
|
||||
# Assert
|
||||
assert footer is not None
|
||||
assert footer.clean_shutdown is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9 — integration: clean-shutdown footer fixture → upload invoked
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingUploader:
|
||||
def __init__(self, report) -> None:
|
||||
self.report = report
|
||||
self.calls: list = []
|
||||
|
||||
def upload_pending_tiles(self, request):
|
||||
self.calls.append(request)
|
||||
return self.report
|
||||
|
||||
|
||||
def test_ac9_integration_clean_shutdown_fixture_triggers_upload(tmp_path) -> None:
|
||||
# Arrange — real segment files on disk + LocalFdrFooterReader + recording uploader.
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
_write_segment(
|
||||
flight_dir, 0, [_make_record(kind="log", payload={"msg": "pre-takeoff"})]
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir, 1, [_make_record(kind="log", payload={"msg": "mid-flight"})]
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir,
|
||||
2,
|
||||
[
|
||||
_make_record(kind="log", payload={"msg": "landing rollout"}),
|
||||
_make_footer_record(flight_id=flight_id, clean_shutdown=True),
|
||||
],
|
||||
)
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
IngestStatusCut,
|
||||
PerTileStatusCut,
|
||||
UploadBatchReportCut,
|
||||
UploadOutcomeCut,
|
||||
)
|
||||
|
||||
fake_report = UploadBatchReportCut(
|
||||
batch_uuid=uuid4(),
|
||||
per_tile_status=(
|
||||
PerTileStatusCut(tile_id="tile-A", status=IngestStatusCut.ACCEPTED),
|
||||
),
|
||||
retry_count=0,
|
||||
next_retry_at_s=None,
|
||||
outcome=UploadOutcomeCut.SUCCESS,
|
||||
public_key_fingerprint="cd" * 8,
|
||||
)
|
||||
uploader = _RecordingUploader(fake_report)
|
||||
orchestrator = PostLandingUploadOrchestrator(
|
||||
tile_uploader=uploader,
|
||||
fdr_footer_reader=reader,
|
||||
logger=logging.getLogger("test.c12.it03a"),
|
||||
config=C12PostLandingConfig(fdr_root=fdr_root),
|
||||
)
|
||||
request = PostLandingUploadRequest(
|
||||
flight_id=flight_id,
|
||||
satellite_provider_url="https://parent.example/ingest",
|
||||
api_key="key-it03a",
|
||||
batch_size=25,
|
||||
)
|
||||
|
||||
# Act
|
||||
returned = orchestrator.trigger_post_landing_upload(request)
|
||||
|
||||
# Assert
|
||||
assert returned is fake_report
|
||||
assert len(uploader.calls) == 1
|
||||
assert uploader.calls[0].flight_id == flight_id
|
||||
assert uploader.calls[0].batch_size == 25
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10 — integration: truncated fixture (no footer anywhere) → refusal
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac10_integration_truncated_fixture_refuses_with_footer_missing(tmp_path) -> None:
|
||||
# Arrange — simulate a truncated flight: segments exist with `log`
|
||||
# records but the writer terminated before close_flight() emitted the
|
||||
# footer record.
|
||||
flight_id = uuid4()
|
||||
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
|
||||
_write_segment(
|
||||
flight_dir, 0, [_make_record(kind="log", payload={"msg": "in flight"})]
|
||||
)
|
||||
_write_segment(
|
||||
flight_dir, 1, [_make_record(kind="log", payload={"msg": "still flying"})]
|
||||
)
|
||||
reader = LocalFdrFooterReader(fdr_root)
|
||||
uploader = _RecordingUploader(None)
|
||||
orchestrator = PostLandingUploadOrchestrator(
|
||||
tile_uploader=uploader,
|
||||
fdr_footer_reader=reader,
|
||||
logger=logging.getLogger("test.c12.it03b"),
|
||||
config=C12PostLandingConfig(fdr_root=fdr_root),
|
||||
)
|
||||
request = PostLandingUploadRequest(
|
||||
flight_id=flight_id,
|
||||
satellite_provider_url="https://parent.example/ingest",
|
||||
api_key="key-it03b",
|
||||
batch_size=25,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
assert exc_info.value.not_confirmed_reason == "footer_missing"
|
||||
assert uploader.calls == []
|
||||
+1
-1
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
FilelockFileLockFactory,
|
||||
LockTimeout,
|
||||
)
|
||||
+1
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
FRESHNESS_TABLE,
|
||||
SectorClassification,
|
||||
freshness_threshold_months,
|
||||
@@ -0,0 +1,432 @@
|
||||
"""AZ-330 OperatorReLocService unit tests.
|
||||
|
||||
Covers AC-1..AC-9 directly against the service with fakes. AC-10 (lazy
|
||||
construction of the transport in the composition root) lives in
|
||||
:mod:`test_cli_help_and_logging` / a composition-root regression — the
|
||||
factory is verified to NOT call the transport constructor unless the
|
||||
operator supplies one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
ReLocHint,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
GcsLinkError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_reloc_service import (
|
||||
OperatorReLocService,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client import EnqueueResult, FdrClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTransport:
|
||||
"""Configurable :class:`OperatorCommandTransport` for the unit tests."""
|
||||
|
||||
raises: GcsLinkError | None = None
|
||||
calls: list[ReLocHint] = field(default_factory=list)
|
||||
|
||||
def send_reloc_hint(self, hint: ReLocHint) -> None:
|
||||
self.calls.append(hint)
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeClock:
|
||||
"""Deterministic Clock — ``monotonic_ns`` increments on every call."""
|
||||
|
||||
next_monotonic_ns: int = 1_000_000_000
|
||||
fixed_time_ns: int = 1_715_600_000_000_000_000 # ~2024-05-13T12:53:20Z
|
||||
|
||||
def monotonic_ns(self) -> int:
|
||||
v = self.next_monotonic_ns
|
||||
self.next_monotonic_ns += 1
|
||||
return v
|
||||
|
||||
def time_ns(self) -> int:
|
||||
return self.fixed_time_ns
|
||||
|
||||
def sleep_until_ns(self, target_ns: int) -> None:
|
||||
_ = target_ns
|
||||
|
||||
|
||||
def _make_fdr_client(*, force_overrun: bool = False) -> FdrClient:
|
||||
client = FdrClient(
|
||||
producer_id="c12_operator_orchestrator",
|
||||
capacity=4,
|
||||
_emit_diag_log=False,
|
||||
)
|
||||
if force_overrun:
|
||||
# Fill the buffer to its rounded-up power-of-two capacity so the
|
||||
# next enqueue returns OVERRUN (AC-8).
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
FdrRecord,
|
||||
)
|
||||
|
||||
filler = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts="2026-05-13T00:00:00.000000+00:00",
|
||||
producer_id=client.producer_id,
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "INFO",
|
||||
"component": "test",
|
||||
"frame_id": "",
|
||||
"kind": "test",
|
||||
"msg": "filler",
|
||||
},
|
||||
)
|
||||
for _ in range(client._capacity()):
|
||||
client.enqueue(filler)
|
||||
return client
|
||||
|
||||
|
||||
def _make_hint(
|
||||
*,
|
||||
lat: float = 49.99876543,
|
||||
lon: float = 36.12345678,
|
||||
alt: float = 1234.5,
|
||||
radius: float = 50.0,
|
||||
reason: str = "lost track at WP3",
|
||||
) -> ReLocHint:
|
||||
return ReLocHint(
|
||||
approximate_position_wgs84=LatLonAlt(lat_deg=lat, lon_deg=lon, alt_m=alt),
|
||||
confidence_radius_m=radius,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _build_service(
|
||||
*,
|
||||
transport: _FakeTransport,
|
||||
fdr_client: FdrClient,
|
||||
clock: _FakeClock,
|
||||
) -> OperatorReLocService:
|
||||
logger = logging.getLogger("c12.operator_reloc_service.test")
|
||||
return OperatorReLocService(
|
||||
transport=transport,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: success → transport called once + INFO log + FDR record "sent"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_request_reloc_success_calls_transport_once_and_emits_fdr(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
transport = _FakeTransport()
|
||||
fdr_client = _make_fdr_client()
|
||||
clock = _FakeClock()
|
||||
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
|
||||
hint = _make_hint()
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO, logger="c12.operator_reloc_service.test"):
|
||||
service.request_reloc(hint)
|
||||
|
||||
# Assert — transport
|
||||
assert len(transport.calls) == 1
|
||||
assert transport.calls[0] is hint
|
||||
|
||||
# Assert — INFO log
|
||||
sent_records = [r for r in caplog.records if r.kind == "c12.reloc.sent"]
|
||||
assert len(sent_records) == 1
|
||||
log_kv = sent_records[0].kv
|
||||
assert log_kv["position_lat"] == pytest.approx(49.99877)
|
||||
assert log_kv["position_lon"] == pytest.approx(36.12346)
|
||||
assert log_kv["confidence_radius_m"] == 50.0
|
||||
assert log_kv["reason"] == "lost track at WP3"
|
||||
assert log_kv["altitude_m"] == 1234.5
|
||||
|
||||
# Assert — exactly one FDR record with outcome="sent"
|
||||
record = fdr_client.pop_one()
|
||||
assert record is not None
|
||||
assert record.kind == "c12.reloc.requested"
|
||||
assert record.payload["outcome"] == "sent"
|
||||
assert record.payload["hint"]["reason"] == "lost track at WP3"
|
||||
assert record.payload["hint"]["lat_deg"] == 49.99876543
|
||||
assert record.payload["hint"]["lon_deg"] == 36.12345678
|
||||
assert record.payload["hint"]["alt_m"] == 1234.5
|
||||
assert record.payload["hint"]["confidence_radius_m"] == 50.0
|
||||
assert "failure_reason" not in record.payload
|
||||
assert isinstance(record.payload["ts_monotonic_ns"], int)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: transport raises GcsLinkError → re-raise + ERROR log + FDR "failed"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_request_reloc_link_failure_reraises_with_c12_prefix_and_records_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
inner = GcsLinkError(
|
||||
reason="link signal lost",
|
||||
wrapped_exception_repr="SerialTimeout(...)",
|
||||
)
|
||||
transport = _FakeTransport(raises=inner)
|
||||
fdr_client = _make_fdr_client()
|
||||
clock = _FakeClock()
|
||||
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
|
||||
hint = _make_hint()
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR, logger="c12.operator_reloc_service.test"):
|
||||
with pytest.raises(GcsLinkError) as exc_info:
|
||||
service.request_reloc(hint)
|
||||
|
||||
# Assert — re-raise + cause chain preserves the original
|
||||
outer = exc_info.value
|
||||
assert outer.reason == "C12 reloc-confirm: link signal lost"
|
||||
assert outer.__cause__ is inner
|
||||
assert outer.wrapped_exception_repr is not None
|
||||
assert "GcsLinkError" in outer.wrapped_exception_repr
|
||||
|
||||
# Assert — ERROR log
|
||||
failed_records = [r for r in caplog.records if r.kind == "c12.reloc.failed"]
|
||||
assert len(failed_records) == 1
|
||||
log_kv = failed_records[0].kv
|
||||
assert log_kv["failure_reason"] == "link signal lost"
|
||||
assert log_kv["wrapped_exception_repr"] == "SerialTimeout(...)"
|
||||
|
||||
# Assert — FDR record records the failure
|
||||
record = fdr_client.pop_one()
|
||||
assert record is not None
|
||||
assert record.kind == "c12.reloc.requested"
|
||||
assert record.payload["outcome"] == "failed"
|
||||
assert record.payload["failure_reason"] == "link signal lost"
|
||||
assert record.payload["hint"]["reason"] == "lost track at WP3"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: ReLocHint(confidence_radius_m=0.0) → ValueError at construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_radius", [0.0, -1.0, -0.0001])
|
||||
def test_reloc_hint_rejects_non_positive_radius(bad_radius: float) -> None:
|
||||
with pytest.raises(ValueError, match="confidence_radius_m must be > 0"):
|
||||
ReLocHint(
|
||||
approximate_position_wgs84=LatLonAlt(lat_deg=49.0, lon_deg=36.0, alt_m=100.0),
|
||||
confidence_radius_m=bad_radius,
|
||||
reason="test",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: reason is preserved byte-for-byte through the transport call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reason_byte_for_byte_through_transport_log_truncated(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
transport = _FakeTransport()
|
||||
fdr_client = _make_fdr_client()
|
||||
clock = _FakeClock()
|
||||
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
|
||||
long_reason = "x" * 300
|
||||
hint = _make_hint(reason=long_reason)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO, logger="c12.operator_reloc_service.test"):
|
||||
service.request_reloc(hint)
|
||||
|
||||
# Assert — transport sees the unchanged hint
|
||||
assert transport.calls[0].reason == long_reason
|
||||
|
||||
# Assert — FDR record preserves the full reason
|
||||
record = fdr_client.pop_one()
|
||||
assert record is not None
|
||||
assert record.payload["hint"]["reason"] == long_reason
|
||||
|
||||
# Assert — INFO log truncates to 200 chars
|
||||
sent_records = [r for r in caplog.records if r.kind == "c12.reloc.sent"]
|
||||
assert len(sent_records) == 1
|
||||
assert sent_records[0].kv["reason"] == "x" * 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5: Contract document exists with the exact method signature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_operator_command_transport_contract_document_exists() -> None:
|
||||
# Arrange
|
||||
from pathlib import Path
|
||||
|
||||
# The path is the current contract location; Phase F may move it
|
||||
# to ``c12_operator_orchestrator/`` — both layouts are accepted so
|
||||
# this test survives the Phase F rename.
|
||||
candidates = [
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md",
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "_docs/02_document/contracts/c12_operator_tooling/operator_command_transport.md",
|
||||
]
|
||||
|
||||
# Act
|
||||
existing = [p for p in candidates if p.exists()]
|
||||
|
||||
# Assert
|
||||
assert existing, "expected operator_command_transport contract to exist in one of the known paths"
|
||||
content = existing[0].read_text(encoding="utf-8")
|
||||
assert "send_reloc_hint" in content
|
||||
assert "ReLocHint" in content
|
||||
assert "GcsLinkError" in content
|
||||
assert "Versioning Rules" in content
|
||||
assert content.count("TC-") >= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: ReLocHint(reason="") → ValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reloc_hint_rejects_empty_reason() -> None:
|
||||
with pytest.raises(ValueError, match="reason must be non-empty"):
|
||||
ReLocHint(
|
||||
approximate_position_wgs84=LatLonAlt(lat_deg=49.0, lon_deg=36.0, alt_m=100.0),
|
||||
confidence_radius_m=50.0,
|
||||
reason="",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-7: lat/lon out of range → ValueError at ReLocHint construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lat, lon",
|
||||
[
|
||||
(91.0, 36.0),
|
||||
(-91.0, 36.0),
|
||||
(49.0, 181.0),
|
||||
(49.0, -180.0), # lower bound is strict
|
||||
],
|
||||
)
|
||||
def test_reloc_hint_rejects_out_of_range_lat_lon(lat: float, lon: float) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ReLocHint(
|
||||
approximate_position_wgs84=LatLonAlt(lat_deg=lat, lon_deg=lon, alt_m=100.0),
|
||||
confidence_radius_m=50.0,
|
||||
reason="test",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-8: FDR enqueue OVERRUN does NOT raise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_request_reloc_succeeds_when_fdr_buffer_overruns() -> None:
|
||||
# Arrange
|
||||
transport = _FakeTransport()
|
||||
fdr_client = _make_fdr_client(force_overrun=True)
|
||||
clock = _FakeClock()
|
||||
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
|
||||
hint = _make_hint()
|
||||
|
||||
enqueue_results: list[str] = []
|
||||
original_enqueue = fdr_client.enqueue
|
||||
|
||||
def spy_enqueue(record):
|
||||
result = original_enqueue(record)
|
||||
enqueue_results.append(result)
|
||||
return result
|
||||
|
||||
fdr_client.enqueue = spy_enqueue # type: ignore[method-assign]
|
||||
|
||||
# Act
|
||||
service.request_reloc(hint)
|
||||
|
||||
# Assert — transport call unaffected; enqueue observed OVERRUN
|
||||
assert len(transport.calls) == 1
|
||||
assert enqueue_results == [EnqueueResult.OVERRUN]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-9: Position logged at 5 decimals; transport sees full precision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_position_logged_at_5_decimals_transport_sees_full_precision(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
transport = _FakeTransport()
|
||||
fdr_client = _make_fdr_client()
|
||||
clock = _FakeClock()
|
||||
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
|
||||
hint = _make_hint(lat=49.99876543, lon=36.12345678, alt=42.0)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO, logger="c12.operator_reloc_service.test"):
|
||||
service.request_reloc(hint)
|
||||
|
||||
# Assert — transport receives full precision
|
||||
sent_hint = transport.calls[0]
|
||||
assert sent_hint.approximate_position_wgs84.lat_deg == 49.99876543
|
||||
assert sent_hint.approximate_position_wgs84.lon_deg == 36.12345678
|
||||
|
||||
# Assert — log is rounded to 5 decimals
|
||||
sent_records = [r for r in caplog.records if r.kind == "c12.reloc.sent"]
|
||||
assert sent_records[0].kv["position_lat"] == pytest.approx(49.99877)
|
||||
assert sent_records[0].kv["position_lon"] == pytest.approx(36.12346)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-10: factory does NOT construct the transport unless one is passed in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_operator_orchestrator_does_not_construct_operator_reloc_service_without_transport(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
from pathlib import Path
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12CompanionConfig,
|
||||
C12Config,
|
||||
HostKeyPolicy,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
|
||||
config = Config()
|
||||
config.components["c12_operator_orchestrator"] = C12Config(
|
||||
log_path=tmp_path / "c12.log",
|
||||
sector_classification_store_path=tmp_path / "sectors.json",
|
||||
companion=C12CompanionConfig(
|
||||
ssh_user="op",
|
||||
ssh_keyfile=Path("/tmp/fake-key"),
|
||||
host_key_policy=HostKeyPolicy.STRICT,
|
||||
),
|
||||
)
|
||||
|
||||
from gps_denied_onboard.runtime_root.c12_factory import build_operator_orchestrator
|
||||
|
||||
# Act — no operator_command_transport supplied
|
||||
services = build_operator_orchestrator(config)
|
||||
|
||||
# Assert
|
||||
assert services.operator_reloc_service is None
|
||||
+1
-1
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
HostKeyPolicy,
|
||||
ParamikoSshSessionFactory,
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
"""AZ-329 PostLandingUploadOrchestrator unit tests.
|
||||
|
||||
Covers AC-1..AC-8 (the orchestrator-level ACs); AC-9/AC-10 live in
|
||||
:mod:`test_post_landing_upload_integration` against real FDR fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
FlightFooterRecord,
|
||||
IngestStatusCut,
|
||||
PerTileStatusCut,
|
||||
PostLandingUploadRequest,
|
||||
UploadBatchReportCut,
|
||||
UploadOutcomeCut,
|
||||
UploadRequestCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12PostLandingConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
FdrUnreadableError,
|
||||
FlightStateNotConfirmedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
|
||||
PostLandingUploadOrchestrator,
|
||||
)
|
||||
|
||||
_API_KEY_LITERAL = "super-secret-token-123"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeFooterReader:
|
||||
"""Configurable :class:`FdrFooterReader` for the orchestrator unit tests."""
|
||||
|
||||
footer: FlightFooterRecord | None = None
|
||||
raises: FdrUnreadableError | None = None
|
||||
calls: list[UUID] = field(default_factory=list)
|
||||
|
||||
def read_footer(self, flight_id: UUID) -> FlightFooterRecord | None:
|
||||
self.calls.append(flight_id)
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return self.footer
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTileUploader:
|
||||
"""Configurable :class:`TileUploaderCut` recording each request."""
|
||||
|
||||
report: UploadBatchReportCut | None = None
|
||||
calls: list[UploadRequestCut] = field(default_factory=list)
|
||||
|
||||
def upload_pending_tiles(self, request: UploadRequestCut) -> UploadBatchReportCut:
|
||||
self.calls.append(request)
|
||||
assert self.report is not None, "test must wire .report before the call"
|
||||
return self.report
|
||||
|
||||
|
||||
def _make_request(flight_id: UUID | None = None) -> PostLandingUploadRequest:
|
||||
return PostLandingUploadRequest(
|
||||
flight_id=flight_id or uuid4(),
|
||||
satellite_provider_url="https://parent.example/ingest",
|
||||
api_key=_API_KEY_LITERAL,
|
||||
batch_size=50,
|
||||
)
|
||||
|
||||
|
||||
def _make_footer(*, flight_id: UUID, clean_shutdown: bool) -> FlightFooterRecord:
|
||||
return FlightFooterRecord(
|
||||
flight_id=flight_id,
|
||||
flight_ended_at_iso="2026-05-13T12:00:00+00:00",
|
||||
records_written=12345,
|
||||
records_dropped_overrun=0 if clean_shutdown else 42,
|
||||
bytes_written=987654 if not clean_shutdown else 654321,
|
||||
rollover_count=0,
|
||||
clean_shutdown=clean_shutdown,
|
||||
)
|
||||
|
||||
|
||||
def _make_report() -> UploadBatchReportCut:
|
||||
return UploadBatchReportCut(
|
||||
batch_uuid=uuid4(),
|
||||
per_tile_status=(
|
||||
PerTileStatusCut(tile_id="tile-0", status=IngestStatusCut.ACCEPTED),
|
||||
PerTileStatusCut(tile_id="tile-1", status=IngestStatusCut.ACCEPTED),
|
||||
PerTileStatusCut(
|
||||
tile_id="tile-2",
|
||||
status=IngestStatusCut.REJECTED,
|
||||
rejection_reason="invalid signature",
|
||||
),
|
||||
),
|
||||
retry_count=0,
|
||||
next_retry_at_s=None,
|
||||
outcome=UploadOutcomeCut.PARTIAL,
|
||||
public_key_fingerprint="ab" * 8,
|
||||
)
|
||||
|
||||
|
||||
def _build_orchestrator(
|
||||
*,
|
||||
tmp_path,
|
||||
footer: FlightFooterRecord | None = None,
|
||||
reader_raises: FdrUnreadableError | None = None,
|
||||
report: UploadBatchReportCut | None = None,
|
||||
create_flight_dir: bool = True,
|
||||
flight_id: UUID | None = None,
|
||||
) -> tuple[
|
||||
PostLandingUploadOrchestrator,
|
||||
_FakeFooterReader,
|
||||
_FakeTileUploader,
|
||||
UUID,
|
||||
logging.Logger,
|
||||
]:
|
||||
fdr_root = tmp_path / "fdr"
|
||||
fdr_root.mkdir()
|
||||
actual_flight_id = flight_id or uuid4()
|
||||
if create_flight_dir:
|
||||
(fdr_root / str(actual_flight_id)).mkdir()
|
||||
reader = _FakeFooterReader(footer=footer, raises=reader_raises)
|
||||
uploader = _FakeTileUploader(report=report)
|
||||
logger = logging.getLogger(f"test.c12.post_landing.{actual_flight_id}")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
orchestrator = PostLandingUploadOrchestrator(
|
||||
tile_uploader=uploader,
|
||||
fdr_footer_reader=reader,
|
||||
logger=logger,
|
||||
config=C12PostLandingConfig(fdr_root=fdr_root),
|
||||
)
|
||||
return orchestrator, reader, uploader, actual_flight_id, logger
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: clean-shutdown footer → upload invoked
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_clean_shutdown_footer_triggers_upload(tmp_path, caplog) -> None:
|
||||
# Arrange
|
||||
flight_id = uuid4()
|
||||
report = _make_report()
|
||||
orchestrator, reader, uploader, _flight_id, _logger = _build_orchestrator(
|
||||
tmp_path=tmp_path,
|
||||
footer=_make_footer(flight_id=flight_id, clean_shutdown=True),
|
||||
report=report,
|
||||
flight_id=flight_id,
|
||||
)
|
||||
request = _make_request(flight_id=flight_id)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
returned = orchestrator.trigger_post_landing_upload(request)
|
||||
|
||||
# Assert
|
||||
assert returned is report
|
||||
assert reader.calls == [flight_id]
|
||||
assert len(uploader.calls) == 1
|
||||
inner = uploader.calls[0]
|
||||
assert inner.flight_id == flight_id
|
||||
assert inner.satellite_provider_url == request.satellite_provider_url
|
||||
assert inner.batch_size == request.batch_size
|
||||
confirmed = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.confirmed_clean_shutdown"
|
||||
]
|
||||
complete = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.complete"
|
||||
]
|
||||
assert len(confirmed) == 1
|
||||
assert len(complete) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: footer absent → footer_missing
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_footer_absent_refuses_with_footer_missing(tmp_path, caplog) -> None:
|
||||
# Arrange
|
||||
orchestrator, _reader, uploader, flight_id, _logger = _build_orchestrator(
|
||||
tmp_path=tmp_path, footer=None
|
||||
)
|
||||
request = _make_request(flight_id=flight_id)
|
||||
|
||||
# Act / Assert
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
|
||||
assert exc_info.value.not_confirmed_reason == "footer_missing"
|
||||
assert exc_info.value.flight_id == str(flight_id)
|
||||
assert exc_info.value.detail == ""
|
||||
assert "No flight_footer record" in exc_info.value.remediation
|
||||
assert uploader.calls == []
|
||||
refusal_logs = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.footer_missing"
|
||||
]
|
||||
assert len(refusal_logs) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: footer with clean_shutdown=False → unclean_shutdown
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_unclean_shutdown_refuses_with_counters_in_detail(tmp_path, caplog) -> None:
|
||||
# Arrange
|
||||
flight_id = uuid4()
|
||||
footer = _make_footer(flight_id=flight_id, clean_shutdown=False)
|
||||
orchestrator, _reader, uploader, _flight_id, _logger = _build_orchestrator(
|
||||
tmp_path=tmp_path,
|
||||
footer=footer,
|
||||
flight_id=flight_id,
|
||||
)
|
||||
request = _make_request(flight_id=flight_id)
|
||||
|
||||
# Act / Assert
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.not_confirmed_reason == "unclean_shutdown"
|
||||
assert f"records_dropped_overrun={footer.records_dropped_overrun}" in err.detail
|
||||
assert f"bytes_written={footer.bytes_written}" in err.detail
|
||||
assert uploader.calls == []
|
||||
refusal_logs = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.unclean_shutdown"
|
||||
]
|
||||
assert len(refusal_logs) == 1
|
||||
refusal = refusal_logs[0]
|
||||
kv = getattr(refusal, "kv", {})
|
||||
assert kv["records_written"] == footer.records_written
|
||||
assert kv["records_dropped_overrun"] == footer.records_dropped_overrun
|
||||
assert kv["bytes_written"] == footer.bytes_written
|
||||
assert kv["rollover_count"] == footer.rollover_count
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: <fdr_root>/<flight_id>/ does not exist → flight_id_not_found
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac4_flight_dir_missing_refuses_with_flight_id_not_found(tmp_path, caplog) -> None:
|
||||
# Arrange
|
||||
orchestrator, reader, uploader, flight_id, _logger = _build_orchestrator(
|
||||
tmp_path=tmp_path,
|
||||
create_flight_dir=False,
|
||||
)
|
||||
request = _make_request(flight_id=flight_id)
|
||||
|
||||
# Act / Assert
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
|
||||
assert exc_info.value.not_confirmed_reason == "flight_id_not_found"
|
||||
assert reader.calls == []
|
||||
assert uploader.calls == []
|
||||
refusal_logs = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.flight_id_not_found"
|
||||
]
|
||||
assert len(refusal_logs) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: reader raises FdrUnreadableError → fdr_unreadable
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac5_fdr_unreadable_refuses_with_inner_repr(tmp_path, caplog) -> None:
|
||||
# Arrange
|
||||
inner_exc = FdrUnreadableError("OSError('input/output error') at segment-0001.fdr")
|
||||
orchestrator, _reader, uploader, flight_id, _logger = _build_orchestrator(
|
||||
tmp_path=tmp_path,
|
||||
reader_raises=inner_exc,
|
||||
)
|
||||
request = _make_request(flight_id=flight_id)
|
||||
|
||||
# Act / Assert
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.not_confirmed_reason == "fdr_unreadable"
|
||||
assert "OSError" in err.detail
|
||||
assert err.__cause__ is inner_exc
|
||||
assert uploader.calls == []
|
||||
refusal_logs = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.fdr_unreadable"
|
||||
]
|
||||
assert len(refusal_logs) == 1
|
||||
kv = getattr(refusal_logs[0], "kv", {})
|
||||
assert "OSError" in kv["fdr_unreadable_repr"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: passthrough — returns the exact UploadBatchReportCut instance
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac7_upload_batch_report_is_passed_through_unchanged(tmp_path) -> None:
|
||||
# Arrange
|
||||
flight_id = uuid4()
|
||||
report = _make_report()
|
||||
orchestrator, _reader, _uploader, _flight_id, _logger = _build_orchestrator(
|
||||
tmp_path=tmp_path,
|
||||
footer=_make_footer(flight_id=flight_id, clean_shutdown=True),
|
||||
report=report,
|
||||
flight_id=flight_id,
|
||||
)
|
||||
|
||||
# Act
|
||||
returned = orchestrator.trigger_post_landing_upload(_make_request(flight_id=flight_id))
|
||||
|
||||
# Assert
|
||||
assert returned is report
|
||||
assert returned.batch_uuid == report.batch_uuid
|
||||
assert returned.per_tile_status == report.per_tile_status
|
||||
assert returned.outcome is report.outcome
|
||||
assert returned.public_key_fingerprint == report.public_key_fingerprint
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: api_key never appears in any log record across every code path
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scenario",
|
||||
["success", "footer_missing", "unclean_shutdown", "flight_id_not_found", "fdr_unreadable"],
|
||||
)
|
||||
def test_ac8_api_key_never_appears_in_logs(tmp_path, caplog, scenario) -> None:
|
||||
# Arrange
|
||||
flight_id = uuid4()
|
||||
if scenario == "success":
|
||||
footer = _make_footer(flight_id=flight_id, clean_shutdown=True)
|
||||
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
|
||||
tmp_path=tmp_path, footer=footer, report=_make_report(), flight_id=flight_id
|
||||
)
|
||||
elif scenario == "footer_missing":
|
||||
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
|
||||
tmp_path=tmp_path, footer=None, flight_id=flight_id
|
||||
)
|
||||
elif scenario == "unclean_shutdown":
|
||||
footer = _make_footer(flight_id=flight_id, clean_shutdown=False)
|
||||
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
|
||||
tmp_path=tmp_path, footer=footer, flight_id=flight_id
|
||||
)
|
||||
elif scenario == "flight_id_not_found":
|
||||
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
|
||||
tmp_path=tmp_path, create_flight_dir=False, flight_id=flight_id
|
||||
)
|
||||
else: # fdr_unreadable
|
||||
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
|
||||
tmp_path=tmp_path,
|
||||
reader_raises=FdrUnreadableError("parse failure with token glimpse"),
|
||||
flight_id=flight_id,
|
||||
)
|
||||
request = _make_request(flight_id=flight_id)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
try:
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
except FlightStateNotConfirmedError:
|
||||
pass
|
||||
|
||||
# Assert — the literal api_key value MUST NOT appear in any log record
|
||||
for record in caplog.records:
|
||||
for field_name in ("msg", "message"):
|
||||
value = getattr(record, field_name, None)
|
||||
assert value is None or _API_KEY_LITERAL not in str(value), (
|
||||
f"api_key leaked into record.{field_name}: {value!r}"
|
||||
)
|
||||
kv = getattr(record, "kv", None)
|
||||
if kv is not None:
|
||||
for k, v in kv.items():
|
||||
assert _API_KEY_LITERAL not in str(v), (
|
||||
f"api_key leaked into record.kv[{k!r}]: {v!r}"
|
||||
)
|
||||
+3
-3
@@ -11,17 +11,17 @@ from uuid import UUID
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
BuildReportParseError,
|
||||
RemoteBuildOutcome,
|
||||
RemoteCacheProvisionerInvoker,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
|
||||
REDACTED_PLACEHOLDER,
|
||||
RemoteBuildRequest,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
RemoteCommandResult,
|
||||
SshSession,
|
||||
)
|
||||
+2
-2
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
SectorClassification,
|
||||
SectorClassificationStore,
|
||||
)
|
||||
@@ -88,7 +88,7 @@ class TestAtomicWriteUnderCrash:
|
||||
raise OSError("simulated kill mid-replace")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gps_denied_onboard.components.c12_operator_tooling."
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator."
|
||||
"sector_classification_store.os.replace",
|
||||
_raise_replace,
|
||||
)
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
# Assert
|
||||
from gps_denied_onboard.components.c12_operator_tooling import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator import (
|
||||
CacheBuildWorkflow,
|
||||
OperatorReLocService,
|
||||
)
|
||||
@@ -33,7 +33,7 @@ REQUIRED_PATHS: tuple[str, ...] = (
|
||||
".gitignore",
|
||||
"README.md",
|
||||
"docker/companion-tier1.Dockerfile",
|
||||
"docker/operator-tooling.Dockerfile",
|
||||
"docker/operator-orchestrator.Dockerfile",
|
||||
"docker/mock-suite-sat-service.Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.test.yml",
|
||||
@@ -71,7 +71,7 @@ COMPONENT_DIRS: tuple[str, ...] = (
|
||||
"c8_fc_adapter",
|
||||
"c10_provisioning",
|
||||
"c11_tile_manager",
|
||||
"c12_operator_tooling",
|
||||
"c12_operator_orchestrator",
|
||||
"c13_fdr",
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_compose_yml_declares_required_services() -> None:
|
||||
data = yaml.safe_load((REPO_ROOT / "docker-compose.yml").read_text())
|
||||
services = data["services"]
|
||||
# Assert
|
||||
for required in ("companion", "operator-tooling", "mock-sat", "db"):
|
||||
for required in ("companion", "operator-orchestrator", "mock-sat", "db"):
|
||||
assert required in services, f"docker-compose.yml missing service: {required}"
|
||||
|
||||
|
||||
|
||||
@@ -255,6 +255,19 @@ def _kind_payload(kind: str) -> dict[str, object]:
|
||||
"attempts": 5,
|
||||
"last_rejection_reason": "invalid signature",
|
||||
}
|
||||
if kind == "c12.reloc.requested":
|
||||
return {
|
||||
"hint": {
|
||||
"lat_deg": 49.99876543,
|
||||
"lon_deg": 36.12345678,
|
||||
"alt_m": 1234.5,
|
||||
"confidence_radius_m": 50.0,
|
||||
"reason": "lost track at WP3",
|
||||
},
|
||||
"outcome": "sent",
|
||||
"failure_reason": None,
|
||||
"ts_monotonic_ns": 1_234_567_890_123,
|
||||
}
|
||||
raise AssertionError(f"unhandled kind in fixture: {kind!r}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user