162 lines
5.0 KiB
Python
162 lines
5.0 KiB
Python
"""Tests for the v4 vessel_ais aggregation strategy."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from app.models.vessel import AISRawObservation
|
|
from app.services.vessel_aggregation_strategy import (
|
|
DEFAULT_STRATEGY,
|
|
StrategyValidationError,
|
|
validate_strategy,
|
|
)
|
|
from app.services.vessel_ais_aggregation import aggregate_vessel_observations
|
|
|
|
|
|
def _obs(*, source: str, mmsi: int, observed_at: datetime, **payload) -> AISRawObservation:
|
|
payload = {"mmsi": mmsi, "lat": 50.0, "lon": 10.0, **payload}
|
|
delivery_mode = "realtime_stream" if source == "aisstream_vessels" else "polling"
|
|
transport = "websocket" if source == "aisstream_vessels" else "http"
|
|
return AISRawObservation(
|
|
target_schema="vessel_ais",
|
|
source=source,
|
|
entity_key=str(mmsi),
|
|
delivery_mode=delivery_mode,
|
|
transport=transport,
|
|
message_type="PositionReport",
|
|
observation_hash=f"{source}:{mmsi}:{observed_at.isoformat()}",
|
|
observed_at=observed_at,
|
|
collected_at=observed_at,
|
|
normalized_payload=payload,
|
|
raw_payload=payload,
|
|
quality_flags=[],
|
|
)
|
|
|
|
|
|
def test_validate_rejects_unknown_field():
|
|
with pytest.raises(StrategyValidationError, match="unknown vessel_ais field"):
|
|
validate_strategy({"vessel_ais": {"field_rules": {"definitely_not_a_field": {"mode": "newest"}}}})
|
|
|
|
|
|
def test_validate_rejects_dynamic_lock_without_flag():
|
|
with pytest.raises(StrategyValidationError, match="allow_dynamic_lock"):
|
|
validate_strategy(
|
|
{
|
|
"vessel_ais": {
|
|
"field_rules": {"lat": {"mode": "source_priority"}},
|
|
"allow_dynamic_lock": False,
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
def test_validate_allows_dynamic_lock_with_flag():
|
|
normalized = validate_strategy(
|
|
{
|
|
"version": 0,
|
|
"vessel_ais": {
|
|
"field_rules": {"lat": {"mode": "source_priority", "source_priority": ["barentswatch_vessels"]}},
|
|
"allow_dynamic_lock": True,
|
|
},
|
|
}
|
|
)
|
|
assert normalized["vessel_ais"]["field_rules"]["lat"]["mode"] == "source_priority"
|
|
assert normalized["version"] == 1
|
|
|
|
|
|
def test_validate_increments_version():
|
|
first = validate_strategy({"version": 5, "vessel_ais": {}})
|
|
assert first["version"] == 6
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strategy_field_rule_promotes_specific_source(monkeypatch):
|
|
now = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc)
|
|
|
|
obs_a = _obs(
|
|
source="aisstream_vessels",
|
|
mmsi=257123000,
|
|
observed_at=now,
|
|
name="AISSTREAM ONE",
|
|
vessel_type_name="Cargo",
|
|
)
|
|
obs_b = _obs(
|
|
source="barentswatch_vessels",
|
|
mmsi=257123000,
|
|
observed_at=now - timedelta(seconds=1),
|
|
name="BARENTSWATCH ONE",
|
|
vessel_type_name="Cargo",
|
|
)
|
|
|
|
strategy = {
|
|
"version": 7,
|
|
"vessel_ais": {
|
|
"source_priority": [],
|
|
"field_rules": {
|
|
"name": {"mode": "source_priority", "source_priority": ["barentswatch_vessels", "aisstream_vessels"]},
|
|
},
|
|
"freshness": {"realtime_stream_seconds": 0, "polling_seconds": 0},
|
|
"allow_dynamic_lock": False,
|
|
},
|
|
}
|
|
|
|
db = AsyncMock()
|
|
vessels = await aggregate_vessel_observations(
|
|
db,
|
|
[obs_a, obs_b],
|
|
write_conflicts=False,
|
|
strategy=strategy,
|
|
)
|
|
assert len(vessels) == 1
|
|
vessel = vessels[0]
|
|
assert vessel["name"] == "BARENTSWATCH ONE"
|
|
assert vessel["field_sources"]["name"] == "barentswatch_vessels"
|
|
assert vessel["selected_reasons"]["name"] == "source_priority"
|
|
assert vessel["aggregation_strategy_version"] == 7
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strategy_freshness_falls_back_to_polling_when_realtime_stale():
|
|
now = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc)
|
|
|
|
stale_realtime = _obs(
|
|
source="aisstream_vessels",
|
|
mmsi=257123000,
|
|
observed_at=now - timedelta(hours=1),
|
|
lat=58.0,
|
|
lon=10.0,
|
|
)
|
|
fresh_polling = _obs(
|
|
source="barentswatch_vessels",
|
|
mmsi=257123000,
|
|
observed_at=now - timedelta(seconds=30),
|
|
lat=60.0,
|
|
lon=11.0,
|
|
)
|
|
|
|
strategy = {
|
|
"version": 1,
|
|
"vessel_ais": {
|
|
"source_priority": ["aisstream_vessels", "barentswatch_vessels"],
|
|
"field_rules": {},
|
|
"freshness": {"realtime_stream_seconds": 900, "polling_seconds": 7200},
|
|
"allow_dynamic_lock": False,
|
|
},
|
|
}
|
|
|
|
db = AsyncMock()
|
|
vessels = await aggregate_vessel_observations(
|
|
db,
|
|
[stale_realtime, fresh_polling],
|
|
write_conflicts=False,
|
|
strategy=strategy,
|
|
)
|
|
assert vessels[0]["field_sources"]["lat"] == "barentswatch_vessels"
|
|
assert vessels[0]["lat"] == 60.0
|
|
|
|
|
|
def test_default_strategy_is_stable():
|
|
assert DEFAULT_STRATEGY["vessel_ais"]["allow_dynamic_lock"] is False
|
|
assert "freshness" in DEFAULT_STRATEGY["vessel_ais"]
|