osint-dashboard/tests/test_vesselapi.py
Sirius DevOps 1f23083351 feat(vessels): VesselAPI quota-capped AIS poller for Middle East blind spot
Add a server-side REST poller for VesselAPI (free tier 150 calls/mo) that
upserts last-known positions into the shared vessel_last_known store when
AISStream is unset. Default box is the Strait of Hormuz (span 3.6 <= 4 deg),
never CONUS/NC (AISStream owns US coasts).

- app/vesselapi.py: worker loop, 4deg span validator, position->marker
  transform (skip suspected_glitch), durable Postgres daily-quota table.
- Local hard cap 5 successful 2xx/UTC day (VESSELAPI_MAX_CALLS_PER_DAY),
  monthly floor from X-RateLimit-Remaining, single request limit=50, no
  nextToken, no filter.sat, no retry-storm.
- keystore VESSELAPI_API_KEY registry entry; config + compose env passthrough
  (app + ingest); wired next to AISStream in main.py lifespan + run_ingester.
- GET /api/vessels docstring notes AISStream and/or VesselAPI cache.
- tests/test_vesselapi.py: 20 unit tests (no network/DB).
2026-08-29 00:18:54 -04:00

298 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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