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