osint-dashboard/tests/test_vesselapi.py

386 lines
13 KiB
Python
Raw Normal View History

"""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.273227.3713 N, 55.164757.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)
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"]