"""Unit tests for the VesselAPI poller (no network, no DB). Covers box span validation, position → marker mapping, glitch skipping, and the daily-quota gate (6th 2xx attempt skipped). ``poll_once`` is driven with an in-memory fake quota store + fake HTTP client; ``upsert_vessel``'s DB/WS side effects are monkeypatched to no-ops so markers can be asserted in ``vessel_last_known``. """ from __future__ import annotations import asyncio from datetime import datetime, timezone import pytest import vesselapi from vesselapi import ( BboxError, budget_allows, choose_box, days_left_in_month, parse_boxes, parse_boxes_validated, poll_once, transform_vesselapi_payload, transform_vesselapi_position, validate_bbox_span, ) from live_layers import vessel_last_known # ── Fake quota store (in-memory, injected) ──────────────────────────────── class FakeStore: def __init__(self, calls: int = 0, remaining: int | None = None): self.calls = calls self.remaining = remaining self.bumps = 0 async def calls_today(self, day): return self.calls async def remaining_today(self, day): return self.remaining async def bump(self, day, remaining): self.calls += 1 self.remaining = remaining self.bumps += 1 return self.calls # ── Fake HTTP client ─────────────────────────────────────────────────────── class FakeResp: def __init__(self, status: int = 200, headers: dict | None = None, data: dict | None = None): self.status_code = status self.headers = headers or {} self._data = data or {} def json(self): return self._data class FakeClient: def __init__(self, *responses: FakeResp): self.responses = list(responses) self.calls: list[tuple] = [] async def get(self, url, params=None, headers=None): self.calls.append((url, params, headers)) return self.responses.pop(0) async def aclose(self): pass def _sample_payload() -> dict: return { "vessels": [ { "mmsi": 422050100, "imo": 9321483, "vessel_name": "HORMUZ STAR", "latitude": 26.5, "longitude": 56.3, "cog": 88.0, "sog": 12.4, "heading": 90, "nav_status": 0, "timestamp": "2026-08-29T12:00:00Z", "suspected_glitch": False, }, { "mmsi": 422050101, "vessel_name": "GLITCHY", "latitude": 26.6, "longitude": 56.4, "cog": 45.0, "sog": 5.0, "heading": 45, "nav_status": 5, "suspected_glitch": True, }, {"mmsi": 422050102, "vessel_name": "NOFIX"}, # no coords → drop ], "nextToken": "deadbeef", } # ── Box span / parsing ───────────────────────────────────────────────────── def test_validate_bbox_span_accepts_hormuz(): validate_bbox_span(25.5, 55.4, 27.3, 57.2) # span 3.6 — no raise def test_validate_bbox_span_rejects_conus(): with pytest.raises(BboxError): validate_bbox_span(24.0, -125.0, 50.0, -66.0) # span 85 def test_validate_bbox_span_rejects_marine_regions_gazetteer_box(): # 25.2732–27.3713 N, 55.1647–57.3419 E → span 4.28 > 4.0. with pytest.raises(BboxError): validate_bbox_span(25.2732, 55.1647, 27.3713, 57.3419) def test_validate_bbox_span_rejects_inverted_axes(): with pytest.raises(BboxError): validate_bbox_span(27.0, 55.0, 25.0, 57.0) def test_parse_boxes_semicolon_and_skip_malformed(): boxes = parse_boxes("25.5,55.4,27.3,57.2; 10,20,11,21; garbage") assert boxes == [(25.5, 55.4, 27.3, 57.2), (10.0, 20.0, 11.0, 21.0)] def test_parse_boxes_validated_skips_over_span(): # Second box is CONUS-sized → dropped, first kept. valid = parse_boxes_validated("25.5,55.4,27.3,57.2;24,-125,50,-66") assert valid == [(25.5, 55.4, 27.3, 57.2)] # ── Position → marker mapping ───────────────────────────────────────────── def test_transform_position_maps_shared_marker_contract(): m = transform_vesselapi_position({ "mmsi": 422050100, "imo": 9321483, "vessel_name": "HORMUZ STAR", "latitude": 26.5, "longitude": 56.3, "heading": 90, "cog": 88.0, "sog": 12.4, "nav_status": 0, "timestamp": "2026-08-29T12:00:00Z", "suspected_glitch": False, }) assert m is not None assert m["id"] == "422050100" assert m["lat"] == 26.5 assert m["lon"] == 56.3 assert m["label"] == "HORMUZ STAR" assert m["heading"] == 90 assert m["speed"] == 12.4 assert m["extra"]["src"] == "vesselapi" assert m["extra"]["mmsi"] == "422050100" assert m["extra"]["imo"] == 9321483 assert m["extra"]["navstat"] == 0 assert m["extra"]["cog"] == 88.0 assert m["extra"]["sog"] == 12.4 assert m["extra"]["timestamp"] == "2026-08-29T12:00:00Z" def test_transform_position_heading_falls_back_to_cog(): m = transform_vesselapi_position({ "mmsi": 123456789, "vessel_name": "X", "latitude": 1.0, "longitude": 2.0, "heading": None, "cog": 123.4, "sog": 5.0, }) assert m["heading"] == 123.4 def test_transform_position_skips_glitch(): assert transform_vesselapi_position({ "mmsi": 123456789, "latitude": 1.0, "longitude": 2.0, "suspected_glitch": True, }) is None def test_transform_position_skips_missing_coords(): assert transform_vesselapi_position({"mmsi": 123456789, "vessel_name": "NOFIX"}) is None def test_transform_payload_skips_glitch_and_nofix_rows(): rows = transform_vesselapi_payload(_sample_payload()) assert [r["id"] for r in rows] == ["422050100"] # ── Quota budget / scheduling ───────────────────────────────────────────── def test_days_left_in_month(): assert days_left_in_month(datetime(2026, 8, 29, tzinfo=timezone.utc)) == 3 def test_budget_allows_local_daily_cap(): # 5 calls already made → 6th is blocked regardless of remaining. assert budget_allows(5, remaining=1000, days_left=3, max_per_day=5) is False def test_budget_allows_monthly_floor(): # remaining 14 ≤ 5*3=15 → skip; 16 > 15 → allow. assert budget_allows(2, remaining=14, days_left=3, max_per_day=5) is False assert budget_allows(2, remaining=16, days_left=3, max_per_day=5) is True def test_budget_allows_unknown_remaining(): assert budget_allows(2, remaining=None, days_left=3, max_per_day=5) is True def test_choose_box_prefers_primary_when_budget_tight(): boxes = [(1, 1, 2, 2), (3, 3, 4, 4), (5, 5, 6, 6)] # 4 calls made, 1 left → always box 0. assert choose_box(boxes, 4, max_per_day=5) == 0 def test_choose_box_round_robins_when_budget_covers_all(): boxes = [(1, 1, 2, 2), (3, 3, 4, 4), (5, 5, 6, 6)] # 0 calls made, 5 left ≥ 3 boxes → round-robin. assert choose_box(boxes, 0, max_per_day=5) == 0 assert choose_box(boxes, 1, max_per_day=5) == 1 assert choose_box(boxes, 2, max_per_day=5) == 2 # ── poll_once integration (fake store + fake client) ───────────────────── def _patch_side_effects(monkeypatch): async def _noop(*a, **k): return None monkeypatch.setattr("tracks.record_position", _noop) monkeypatch.setattr("geofence.record_and_notify", _noop) monkeypatch.setattr(vesselapi, "persist_vessel_snapshot", _noop) monkeypatch.setattr(vesselapi, "purge_old_vessels", _noop) def test_poll_once_lands_markers_in_vessel_last_known(monkeypatch): _patch_side_effects(monkeypatch) vessel_last_known.clear() client = FakeClient( FakeResp(200, {"X-RateLimit-Remaining": "140"}, _sample_payload()), ) monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client)) store = FakeStore() ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key")) assert ok is True assert store.calls == 1 assert "422050100" in vessel_last_known assert vessel_last_known["422050100"]["extra"]["src"] == "vesselapi" assert "422050101" not in vessel_last_known # glitch skipped # One HTTP call, bounding-box params, no sat / no nextToken follow. assert len(client.calls) == 1 _url, params, headers = client.calls[0] assert params["filter.latBottom"] == "25.5" assert params["filter.latTop"] == "27.3" assert params["filter.lonLeft"] == "55.4" assert params["filter.lonRight"] == "57.2" assert params["pagination.limit"] == "50" assert "sat" not in params assert headers["Authorization"] == "Bearer test-key" def test_poll_once_sixth_2xx_is_skipped_with_zero_http(monkeypatch): client = FakeClient() monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client)) # 5 successful calls already today → 6th poll makes no HTTP request. store = FakeStore(calls=5, remaining=1000) ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key")) assert ok is False assert store.bumps == 0 assert client.calls == [] def test_poll_once_4xx_not_counted_and_no_upsert(monkeypatch): _patch_side_effects(monkeypatch) vessel_last_known.clear() client = FakeClient(FakeResp(400, {}, {"error": {}})) monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client)) store = FakeStore() ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key")) assert ok is False assert store.bumps == 0 # 4xx does not count against quota assert vessel_last_known == {} def _make_get_client(client): async def _get_client(): return client return _get_client def test_eviction_keeps_vesselapi_rows(monkeypatch): """AISStream crowding past _MAX_VESSELS must not reap Hormuz VesselAPI rows.""" from ws_manager import manager from live_layers import _MAX_VESSELS, upsert_vessel _patch_side_effects(monkeypatch) monkeypatch.setattr("live_layers._MAX_VESSELS", 10) vessel_last_known.clear() manager._queues.clear() manager._viewports.clear() # One Hormuz VesselAPI row with the oldest seen_at — the first thing the # old "evict oldest" logic would reap — plus enough AISStream rows to # exceed the cap. vessel_last_known["422050100"] = { "id": "422050100", "lat": 26.5, "lon": 56.3, "label": "HORMUZ STAR", "extra": {"src": "vesselapi"}, "seen_at": "2026-08-29T00:00:00+00:00", } for i in range(10): vid = f"3{i:08d}" vessel_last_known[vid] = { "id": vid, "lat": 35.0 + i * 0.01, "lon": -79.0, "label": vid, "extra": {"src": "aisstream"}, "seen_at": f"2026-08-29T0{i}:00:00+00:00", } # One more AISStream marker pushes past the cap and triggers eviction. asyncio.run(upsert_vessel({"id": "399999999", "lat": 36.0, "lon": -78.0, "label": "NEW"})) assert "422050100" in vessel_last_known # VesselAPI row survives assert vessel_last_known["422050100"]["extra"]["src"] == "vesselapi" assert len(vessel_last_known) <= 10 # ── Independent-provider idle behaviour ─────────────────────────────────── # VesselAPI must never be gated on AISStream (or vice versa): a missing key on # one provider leaves the other running. ``_StopLoop`` is a BaseException so # the worker's ``except Exception`` handler can't swallow it — the first # ``asyncio.sleep`` aborts the loop after exactly one decision. class _StopLoop(BaseException): pass async def _stop_sleep(*_a, **_k): raise _StopLoop() async def _resolve(value: str): return value def test_worker_idles_without_vesselapi_key_even_if_aisstream_set(monkeypatch): # AISStream key present, VesselAPI key absent -> no poll, still idles. monkeypatch.setenv("AISSTREAM_API_KEY", "unused-aisstream-key") monkeypatch.setenv("VESSELAPI_API_KEY", "") monkeypatch.setattr(vesselapi, "_resolve_key", lambda: _resolve("")) monkeypatch.setattr(vesselapi.asyncio, "sleep", _stop_sleep) poll_calls: list = [] async def _spy_poll(store, boxes, key): poll_calls.append(key) return True monkeypatch.setattr(vesselapi, "poll_once", _spy_poll) with pytest.raises(_StopLoop): asyncio.run(vesselapi.run_vesselapi_worker(FakeStore())) assert poll_calls == [] def test_worker_polls_with_vesselapi_key_even_if_aisstream_unset(monkeypatch): # AISStream key absent, VesselAPI key present -> still polls exactly once. monkeypatch.setenv("AISSTREAM_API_KEY", "") monkeypatch.setattr(vesselapi, "_resolve_key", lambda: _resolve("vesselapi-key")) monkeypatch.setattr(vesselapi.asyncio, "sleep", _stop_sleep) poll_calls: list = [] async def _spy_poll(store, boxes, key): poll_calls.append(key) return True monkeypatch.setattr(vesselapi, "poll_once", _spy_poll) with pytest.raises(_StopLoop): asyncio.run(vesselapi.run_vesselapi_worker(FakeStore())) assert poll_calls == ["vesselapi-key"]