mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 19:41:13 +00:00
90f4ac78f4
Lands the operator-side pre-flight download path: authenticated httpx GETs against satellite-provider, RESTRICT-SAT-4 (>= 0.5 m/px) enforcement at the C11 boundary, c6 writes via consumer-side cuts (_TileWriterLike, _BudgetEnforcerLike), per-(flight_id, request_hash) journal under cache_root/.c11/journal/ for idempotent re-runs (AC-8, AC-12), 429 Retry-After + 5xx exponential backoff handling, fail-fast on TLS / 401 / 403, and a redacted-bearer auth-header policy. Architecture: - AZ-507 cross-component rule held: tile_downloader.py imports zero c6 symbols; the composition-root _C6DownloadAdapter in runtime_root/c11_factory.py absorbs c6's TileMetadata / TileSource / FreshnessLabel / VotingStatus enum assembly. - Sleep-callable injection (not full Clock) per Batch 39 precedent; default routes through WallClock.sleep_until_ns to keep the AZ-398 invariant intact. - No FDR records on the download path; spec mandates structured logs only (8 log kinds wired: session.start/end, resolution_rejected, freshness_rejected_summary, freshness_downgraded, batch.retry, provider.failed, budget.exceeded, idempotent_no_op). Tests: 14 new downloader unit tests covering AC-1..AC-9, AC-11, AC-12 plus throughput NFR + 429 HTTP-date + 429 budget exhaustion; 2 new TileDownloader Protocol conformance tests (AC-10). Full unit suite: 1420 passed, 80 skipped (env-gated), 0 failed. Code review: PASS_WITH_WARNINGS (5 Low findings, all documentation or downstream-blocked). See _docs/03_implementation/reviews/ batch_40_review.md and batch_40_cycle1_report.md. Co-authored-by: Cursor <cursoragent@cursor.com>
740 lines
24 KiB
Python
740 lines
24 KiB
Python
"""AZ-316 ``HttpTileDownloader`` unit tests.
|
|
|
|
Covers AC-1 .. AC-12 plus the throughput NFR from
|
|
``_docs/02_tasks/todo/AZ-316_c11_tile_downloader.md``. Uses
|
|
:class:`httpx.MockTransport` for deterministic HTTP responses, a
|
|
list-backed log handler for log capture, and stub C6 stores so this
|
|
suite never depends on AZ-303 / AZ-305 / AZ-307 / AZ-308 internals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from gps_denied_onboard.components.c11_tile_manager import (
|
|
C11Config,
|
|
CacheBudgetExceededError,
|
|
DownloadOutcome,
|
|
DownloadRequest,
|
|
HttpTileDownloader,
|
|
RateLimitedError,
|
|
SatelliteProviderError,
|
|
SectorClassification,
|
|
request_hash,
|
|
)
|
|
|
|
|
|
_BASE_URL = "https://parent-suite.test"
|
|
_LIST_PATH = "/api/satellite/tiles"
|
|
_API_KEY = "test-api-key-001"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Stubs / fakes
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class _StubFreshnessRejection(Exception):
|
|
"""Composition-root would surface c6's ``FreshnessRejectionError``;
|
|
we mirror it locally so the structural check in the downloader
|
|
matches by class name without importing c6.
|
|
"""
|
|
|
|
|
|
# Re-name the class so the structural check (`__class__.__name__ ==
|
|
# "FreshnessRejectionError"`) inside the downloader matches.
|
|
_StubFreshnessRejection.__name__ = "FreshnessRejectionError"
|
|
|
|
|
|
class _StubTileWriter:
|
|
"""Captures `write_tile_for_download` calls + scripts the freshness label."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
labels: dict[str, str] | None = None,
|
|
rejected: set[str] | None = None,
|
|
) -> None:
|
|
self.labels = labels or {}
|
|
self.rejected = rejected or set()
|
|
self.write_calls: list[dict[str, Any]] = []
|
|
self.exists_calls: list[tuple[int, float, float]] = []
|
|
|
|
def write_tile_for_download(
|
|
self,
|
|
*,
|
|
tile_blob: bytes,
|
|
zoom_level: int,
|
|
lat: float,
|
|
lon: float,
|
|
tile_size_meters: float,
|
|
tile_size_pixels: int,
|
|
capture_timestamp: datetime,
|
|
content_sha256_hex: str,
|
|
sector_class: str,
|
|
) -> str:
|
|
tid = _tid(zoom_level, lat, lon)
|
|
self.write_calls.append(
|
|
{
|
|
"tile_id": tid,
|
|
"tile_blob_len": len(tile_blob),
|
|
"content_sha256_hex": content_sha256_hex,
|
|
"sector_class": sector_class,
|
|
}
|
|
)
|
|
if tid in self.rejected:
|
|
raise _StubFreshnessRejection(f"freshness rejected {tid}")
|
|
return self.labels.get(tid, "fresh")
|
|
|
|
def tile_already_present(
|
|
self, *, zoom_level: int, lat: float, lon: float
|
|
) -> bool:
|
|
self.exists_calls.append((zoom_level, lat, lon))
|
|
return False
|
|
|
|
|
|
class _StubBudgetEnforcer:
|
|
"""Records `reserve_headroom` calls; raises pre-baked exception when set."""
|
|
|
|
def __init__(self, raise_on_call: Exception | None = None) -> None:
|
|
self.calls: list[int] = []
|
|
self._raise = raise_on_call
|
|
|
|
def reserve_headroom(self, needed_bytes: int) -> object:
|
|
self.calls.append(int(needed_bytes))
|
|
if self._raise is not None:
|
|
raise self._raise
|
|
return object()
|
|
|
|
|
|
def _tid(zoom: int, lat: float, lon: float) -> str:
|
|
return f"z{int(zoom)}_{float(lat):.6f}_{float(lon):.6f}"
|
|
|
|
|
|
def _build_downloader(
|
|
*,
|
|
transport: httpx.MockTransport,
|
|
tile_writer: _StubTileWriter | None = None,
|
|
budget_enforcer: _StubBudgetEnforcer | None = None,
|
|
config: C11Config | None = None,
|
|
sleep_recorder: list[float] | None = None,
|
|
backoff_schedule_s: tuple[float, ...] | None = None,
|
|
) -> tuple[
|
|
HttpTileDownloader,
|
|
list[logging.LogRecord],
|
|
_StubTileWriter,
|
|
_StubBudgetEnforcer,
|
|
list[float],
|
|
]:
|
|
log_records: list[logging.LogRecord] = []
|
|
|
|
class _Handler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
log_records.append(record)
|
|
|
|
logger = logging.getLogger(f"test_az316_{id(log_records)}")
|
|
logger.handlers.clear()
|
|
logger.addHandler(_Handler())
|
|
logger.setLevel(logging.DEBUG)
|
|
logger.propagate = False
|
|
|
|
writer = tile_writer or _StubTileWriter()
|
|
enforcer = budget_enforcer or _StubBudgetEnforcer()
|
|
sleeps = sleep_recorder if sleep_recorder is not None else []
|
|
|
|
cfg = config or C11Config(
|
|
satellite_provider_url=_BASE_URL,
|
|
service_api_key=_API_KEY,
|
|
download_http_timeout_s=5.0,
|
|
download_max_5xx_retries=4,
|
|
download_max_retry_after_s=600,
|
|
download_resolution_floor_m_per_px=0.5,
|
|
)
|
|
|
|
client = httpx.Client(transport=transport, base_url=_BASE_URL)
|
|
downloader = HttpTileDownloader(
|
|
http_client=client,
|
|
tile_writer=writer, # type: ignore[arg-type]
|
|
budget_enforcer=enforcer, # type: ignore[arg-type]
|
|
logger=logger,
|
|
config=cfg,
|
|
sleep=lambda s: sleeps.append(s),
|
|
backoff_schedule_s=backoff_schedule_s,
|
|
)
|
|
return downloader, log_records, writer, enforcer, sleeps
|
|
|
|
|
|
def _make_request(
|
|
*,
|
|
flight_id: Any | None = None,
|
|
cache_root: Path,
|
|
zoom_levels: tuple[int, ...] = (14,),
|
|
) -> DownloadRequest:
|
|
return DownloadRequest(
|
|
flight_id=flight_id or uuid4(),
|
|
bbox_min_lat=45.0,
|
|
bbox_min_lon=-122.5,
|
|
bbox_max_lat=45.5,
|
|
bbox_max_lon=-122.0,
|
|
zoom_levels=zoom_levels,
|
|
sector_class=SectorClassification.STABLE_REAR,
|
|
cache_root=cache_root,
|
|
)
|
|
|
|
|
|
def _list_response(
|
|
tiles: list[dict[str, Any]] | None = None,
|
|
) -> httpx.Response:
|
|
return httpx.Response(200, json={"tiles": tiles or []})
|
|
|
|
|
|
def _tile_entry(
|
|
*,
|
|
zoom: int,
|
|
lat: float,
|
|
lon: float,
|
|
resolution_m_per_px: float = 0.5,
|
|
estimated_bytes: int = 4096,
|
|
produced_at: datetime | None = None,
|
|
) -> dict[str, Any]:
|
|
produced = produced_at or datetime(2026, 5, 13, 0, 0, 0, tzinfo=timezone.utc)
|
|
return {
|
|
"tile_id": _tid(zoom, lat, lon),
|
|
"zoom_level": zoom,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"produced_at": produced.isoformat(),
|
|
"resolution_m_per_px": resolution_m_per_px,
|
|
"estimated_bytes": estimated_bytes,
|
|
"tile_size_meters": 100.0,
|
|
"tile_size_pixels": 256,
|
|
}
|
|
|
|
|
|
def _make_route_handler(
|
|
*,
|
|
list_response: httpx.Response | None = None,
|
|
tile_response_factory: Any = None,
|
|
) -> Any:
|
|
"""Route GETs by URL path: list endpoint vs per-tile endpoint."""
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path
|
|
is_list = (
|
|
path.endswith(_LIST_PATH)
|
|
and request.url.params.get("list-only") == "true"
|
|
)
|
|
if is_list:
|
|
return list_response or _list_response()
|
|
if tile_response_factory is None:
|
|
return httpx.Response(200, content=b"\xff\xd8\xff\xe0fake-jpeg")
|
|
return tile_response_factory(request)
|
|
|
|
return _handler
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: 100-tile happy path
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac1_100_tile_happy_path_writes_all(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [
|
|
_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0 - i * 0.001)
|
|
for i in range(100)
|
|
]
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(list_response=_list_response(tiles))
|
|
)
|
|
(downloader, _logs, writer, enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
request = _make_request(cache_root=tmp_path)
|
|
|
|
# Act
|
|
report = downloader.download_tiles_for_area(request)
|
|
|
|
# Assert
|
|
assert report.outcome == DownloadOutcome.SUCCESS
|
|
assert report.tiles_requested == 100
|
|
assert report.tiles_downloaded == 100
|
|
assert report.tiles_rejected_resolution == 0
|
|
assert report.tiles_rejected_freshness == 0
|
|
assert report.tiles_downgraded == 0
|
|
assert len(writer.write_calls) == 100
|
|
assert enforcer.calls == [4096 * 100]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: resolution gate rejects sub-spec tiles BEFORE write
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac2_resolution_gate_rejects_sub_spec_tiles(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = []
|
|
for i in range(50):
|
|
res = 0.3 if i < 10 else 0.5
|
|
tiles.append(
|
|
_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0, resolution_m_per_px=res)
|
|
)
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(list_response=_list_response(tiles))
|
|
)
|
|
(downloader, log_records, writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
|
|
# Act
|
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
# Assert
|
|
assert report.tiles_rejected_resolution == 10
|
|
assert report.tiles_downloaded == 40
|
|
assert len(writer.write_calls) == 40
|
|
res_warnings = [r for r in log_records if getattr(r, "kind", "") == "c11.download.resolution_rejected"]
|
|
assert len(res_warnings) == 10
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: c6 freshness rejection counted, not propagated
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac3_freshness_rejections_counted_and_run_continues(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(10)]
|
|
rejected_ids = {_tid(14, 45.0 + i * 0.001, -122.0) for i in range(5)}
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(list_response=_list_response(tiles))
|
|
)
|
|
writer = _StubTileWriter(rejected=rejected_ids)
|
|
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport, tile_writer=writer
|
|
)
|
|
|
|
# Act
|
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
# Assert
|
|
assert report.tiles_rejected_freshness == 5
|
|
assert report.tiles_downloaded == 5
|
|
assert report.outcome == DownloadOutcome.SUCCESS
|
|
summary_warns = [
|
|
r for r in log_records if getattr(r, "kind", "") == "c11.download.freshness_rejected_summary"
|
|
]
|
|
assert len(summary_warns) == 1
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: stable_rear stale tiles are surfaced as DOWNGRADED
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac4_downgraded_label_counted_and_persisted(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(5)]
|
|
labels = {_tid(14, 45.0 + i * 0.001, -122.0): "downgraded" for i in range(3)}
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(list_response=_list_response(tiles))
|
|
)
|
|
writer = _StubTileWriter(labels=labels)
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport, tile_writer=writer
|
|
)
|
|
|
|
# Act
|
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
# Assert
|
|
assert report.tiles_downgraded == 3
|
|
assert report.tiles_downloaded == 5
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: 429 honours Retry-After
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac5_429_honours_retry_after(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
state = {"attempts": 0}
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
state["attempts"] += 1
|
|
if state["attempts"] == 1:
|
|
return httpx.Response(429, headers={"Retry-After": "30"})
|
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
sleeps: list[float] = []
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport, sleep_recorder=sleeps
|
|
)
|
|
|
|
# Act
|
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
# Assert
|
|
assert state["attempts"] == 2
|
|
assert sleeps and sleeps[0] >= 30
|
|
assert report.retry_count >= 1
|
|
assert report.outcome == DownloadOutcome.SUCCESS
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: persistent 5xx aborts with structured error
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac6_persistent_5xx_raises_satellite_provider_error(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
state = {"attempts": 0}
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
state["attempts"] += 1
|
|
return httpx.Response(503)
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
|
|
# Act / Assert
|
|
with pytest.raises(SatelliteProviderError):
|
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
assert state["attempts"] >= 5
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: 401 fails fast (no retry)
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac7_401_fails_fast_no_retry(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
state = {"attempts": 0}
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
state["attempts"] += 1
|
|
return httpx.Response(401)
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
|
|
# Act / Assert
|
|
with pytest.raises(SatelliteProviderError):
|
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
assert state["attempts"] == 1
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8: idempotent re-run after success
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac8_idempotent_rerun_yields_no_op(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(5)]
|
|
state = {"tile_gets": 0}
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
state["tile_gets"] += 1
|
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
request = _make_request(cache_root=tmp_path)
|
|
|
|
# Act — first run
|
|
first = downloader.download_tiles_for_area(request)
|
|
first_get_count = state["tile_gets"]
|
|
# Act — second run (same request, same cache_root → idempotent)
|
|
second = downloader.download_tiles_for_area(request)
|
|
|
|
# Assert
|
|
assert first.outcome == DownloadOutcome.SUCCESS
|
|
assert second.outcome == DownloadOutcome.IDEMPOTENT_NO_OP
|
|
assert state["tile_gets"] == first_get_count, "second run must NOT fetch"
|
|
assert second.tiles_downloaded == first.tiles_downloaded
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-9: cache-budget pre-check aborts before any GET
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0, estimated_bytes=10_000)]
|
|
transport_state = {"tile_gets": 0}
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
transport_state["tile_gets"] += 1
|
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
enforcer = _StubBudgetEnforcer(
|
|
raise_on_call=CacheBudgetExceededError("no headroom")
|
|
)
|
|
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport, budget_enforcer=enforcer
|
|
)
|
|
|
|
# Act / Assert
|
|
with pytest.raises(CacheBudgetExceededError):
|
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
assert transport_state["tile_gets"] == 0
|
|
assert enforcer.calls == [10_000]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-11: service API key never logged in plaintext
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac11_service_api_key_never_appears_in_logs(tmp_path: Path) -> None:
|
|
# Arrange — exercise the failure path so the provider-failed ERROR
|
|
# log fires (the code that explicitly redacts the auth header).
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(401)
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
with pytest.raises(SatelliteProviderError):
|
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
# Assert
|
|
flat = " ".join(
|
|
r.getMessage() + json.dumps(getattr(r, "kv", {})) for r in log_records
|
|
)
|
|
assert _API_KEY not in flat
|
|
assert "Bearer ***" in flat
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-12: journal survives mid-batch crash; re-run completes the rest
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_ac12_partial_journal_resumed_on_rerun(tmp_path: Path) -> None:
|
|
# Arrange — 10 tiles; first run fetches all 10 successfully and
|
|
# leaves a complete journal. A second run with the SAME request
|
|
# must short-circuit (AC-8 covers that). To exercise AC-12 we
|
|
# MANUALLY truncate the journal between runs to simulate a crash
|
|
# AFTER 4 tile-writes, BEFORE the completed_at_iso stamp.
|
|
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(10)]
|
|
state = {"tile_gets": 0}
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
state["tile_gets"] += 1
|
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
(downloader, _logs, writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
request = _make_request(cache_root=tmp_path)
|
|
|
|
# First run — completes
|
|
downloader.download_tiles_for_area(request)
|
|
after_first = state["tile_gets"]
|
|
write_calls_after_first = len(writer.write_calls)
|
|
assert after_first == 10
|
|
assert write_calls_after_first == 10
|
|
|
|
# Simulate crash: rewrite the journal as "completed 4 of 10 tiles,
|
|
# NOT yet completed" (clear `completed_at_iso`).
|
|
rh = request_hash(
|
|
request.flight_id,
|
|
request.bbox_min_lat,
|
|
request.bbox_min_lon,
|
|
request.bbox_max_lat,
|
|
request.bbox_max_lon,
|
|
tuple(request.zoom_levels),
|
|
request.sector_class,
|
|
_API_KEY,
|
|
)
|
|
journal_path = tmp_path / ".c11/journal" / f"{request.flight_id}__{rh}.json"
|
|
raw = json.loads(journal_path.read_text("utf-8"))
|
|
raw["completed_at_iso"] = None
|
|
raw["tile_ids_completed"] = sorted(raw["tile_ids_completed"])[:4]
|
|
raw["tile_counts"]["tiles_downloaded"] = 4
|
|
journal_path.write_text(json.dumps(raw), encoding="utf-8")
|
|
|
|
# Reset transport counter (writer still records cumulative calls)
|
|
state["tile_gets"] = 0
|
|
writer.write_calls.clear()
|
|
|
|
# Act — second run must fetch only the missing 6
|
|
second = downloader.download_tiles_for_area(request)
|
|
|
|
# Assert
|
|
assert state["tile_gets"] == 6
|
|
assert len(writer.write_calls) == 6
|
|
assert second.outcome == DownloadOutcome.SUCCESS
|
|
assert second.tiles_downloaded == 6
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Retry-After HTTP-date form (Risk 1 from the spec)
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_429_retry_after_http_date_form_parses(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
state = {"attempts": 0}
|
|
future = (datetime.now(timezone.utc) + timedelta(seconds=20)).strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT"
|
|
)
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
state["attempts"] += 1
|
|
if state["attempts"] == 1:
|
|
return httpx.Response(429, headers={"Retry-After": future})
|
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
sleeps: list[float] = []
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport, sleep_recorder=sleeps
|
|
)
|
|
|
|
# Act
|
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
# Assert
|
|
assert state["attempts"] == 2
|
|
assert sleeps and sleeps[0] >= 0
|
|
assert report.outcome == DownloadOutcome.SUCCESS
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 429 budget exhaustion → RateLimitedError
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_429_budget_exhaustion_raises_rate_limited_error(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
|
|
def _factory(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(429, headers={"Retry-After": "300"})
|
|
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=_factory,
|
|
)
|
|
)
|
|
cfg = C11Config(
|
|
satellite_provider_url=_BASE_URL,
|
|
service_api_key=_API_KEY,
|
|
download_http_timeout_s=5.0,
|
|
download_max_5xx_retries=4,
|
|
download_max_retry_after_s=400,
|
|
download_resolution_floor_m_per_px=0.5,
|
|
)
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport, config=cfg
|
|
)
|
|
|
|
# Act / Assert
|
|
with pytest.raises(RateLimitedError):
|
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# NFR — bookkeeping throughput on a 1000-tile happy path
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_nfr_throughput_1000_tiles_under_budget(tmp_path: Path) -> None:
|
|
# Arrange
|
|
tiles = [
|
|
_tile_entry(zoom=14, lat=45.0 + i * 0.0001, lon=-122.0 + i * 0.0001)
|
|
for i in range(1000)
|
|
]
|
|
transport = httpx.MockTransport(
|
|
_make_route_handler(
|
|
list_response=_list_response(tiles),
|
|
tile_response_factory=lambda r: httpx.Response(200, content=b"\xff\xd8tile"),
|
|
)
|
|
)
|
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
transport=transport
|
|
)
|
|
|
|
import time as _time
|
|
|
|
t0 = _time.perf_counter()
|
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
|
elapsed = _time.perf_counter() - t0
|
|
|
|
# Assert — budget is generous; the goal is to catch an O(n^2)
|
|
# bookkeeping regression, not to certify wall-clock throughput.
|
|
assert report.outcome == DownloadOutcome.SUCCESS
|
|
assert report.tiles_downloaded == 1000
|
|
assert elapsed < 10.0, f"1000-tile bookkeeping took {elapsed:.2f}s"
|