- keystore KEY_REGISTRY copy splits the two keys (open/shared US-coast WS vs commercial Hormuz 5x/day REST); VesselAPI no longer reads as an AIS backup. - vesselapi.py docstring drops 'fallback'; states both are first-class. - /api/vessels docstring: last-known is the union (extra.src aisstream vs vesselapi). - tests: VesselAPI idles/polls independently of the AISStream key.
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""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)
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── 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"]
|