from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock import pytest from httpx import ASGITransport, AsyncClient from app.api.v1 import visualization from app.api.v1.visualization import convert_vessels_to_geojson from app.db.session import get_db from app.main import app from app.models.vessel import AISRawObservation, VesselPosition, VesselStatic from app.services import barentswatch from app.services.collectors.aisstream import AISStreamCollector from app.services.collectors.vessel_ais import VesselAISCollector from app.services.vessel_ais_aggregation import ( aggregate_vessel_observations, build_field_conflict_candidates, build_observation_hash, record_vessel_ais_observation, ) def test_vessel_collector_transforms_barentswatch_like_records(): collector = VesselAISCollector() records = collector.transform( [ { "mmsi": "257123000", "lat": "59.91", "lon": "10.73", "sog": 12.4, "cog": 214, "nav_status": 0, "shipType": 70, "name": "OSLO TRADER", }, {"mmsi": "bad", "lat": 120, "lon": 10}, ] ) assert len(records) == 1 assert records[0]["mmsi"] == 257123000 assert records[0]["vessel_type_name"] == "Cargo" assert records[0]["lat"] == pytest.approx(59.91) def test_vessel_observation_hash_is_stable_for_same_payload(): observed_at = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc) payload = { "mmsi": 257123000, "lat": 59.91, "lon": 10.73, "received_at": observed_at, } first = build_observation_hash( source="barentswatch_vessels", entity_key="257123000", message_type="PositionReport", observed_at=observed_at, normalized_payload=payload, ) second = build_observation_hash( source="barentswatch_vessels", entity_key="257123000", message_type="PositionReport", observed_at=observed_at, normalized_payload=dict(reversed(payload.items())), ) assert first == second assert len(first) == 64 @pytest.mark.asyncio async def test_record_vessel_ais_observation_skips_existing_hash(): observed_at = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc) class _Result: def scalar_one_or_none(self): return 123 class _Session: def __init__(self): self.added = [] async def execute(self, _stmt): return _Result() def add(self, item): self.added.append(item) db = _Session() observation = await record_vessel_ais_observation( db, source="barentswatch_vessels", normalized_payload={ "mmsi": 257123000, "lat": 59.91, "lon": 10.73, "received_at": observed_at, }, delivery_mode="polling", transport="http", observed_at=observed_at.isoformat(), ) assert observation is None assert db.added == [] def test_build_field_conflict_candidates_from_raw_observations(): observations = [ AISRawObservation( source="barentswatch_vessels", normalized_payload={"name": "OSLO TRADER", "flag": "NO"}, ), AISRawObservation( source="aisstream_vessels", normalized_payload={"name": "OSLO TRADER II", "flag": "NO"}, ), ] conflicts = build_field_conflict_candidates(observations) assert conflicts == [ { "field": "name", "candidates": { "aisstream_vessels": "OSLO TRADER II", "barentswatch_vessels": "OSLO TRADER", }, "status": "candidate", } ] @pytest.mark.asyncio async def test_aggregate_vessel_observations_prefers_realtime_and_records_conflict(): observed_at = datetime.now(timezone.utc) - timedelta(minutes=5) class _Result: def scalar_one_or_none(self): return None class _Session: def __init__(self): self.added = [] async def execute(self, _stmt): return _Result() def add(self, item): self.added.append(item) db = _Session() observations = [ AISRawObservation( id=1, source="barentswatch_vessels", entity_key="257123000", delivery_mode="polling", transport="http", observed_at=observed_at, collected_at=observed_at, normalized_payload={ "mmsi": 257123000, "name": "OSLO TRADER", "lat": 59.91, "lon": 10.73, }, ), AISRawObservation( id=2, source="aisstream_vessels", entity_key="257123000", delivery_mode="realtime_stream", transport="websocket", observed_at=observed_at + timedelta(seconds=10), collected_at=observed_at + timedelta(seconds=10), normalized_payload={ "mmsi": 257123000, "vessel_type": 79, "lat": 59.92, "lon": 10.74, }, raw_payload={"MetaData": {"ShipName": "OSLO TRADER II "}}, ), ] vessels = await aggregate_vessel_observations(db, observations) assert vessels[0]["lat"] == pytest.approx(59.92) assert vessels[0]["field_sources"]["lat"] == "aisstream_vessels" assert vessels[0]["name"] == "OSLO TRADER II" assert vessels[0]["vessel_type_name"] == "Cargo" assert vessels[0]["source_summary"]["aisstream_vessels"]["observation_count"] == 1 assert vessels[0]["source_summary"]["barentswatch_vessels"]["delivery_mode"] == "polling" assert vessels[0]["conflict_count"] == 0 assert db.added == [] @pytest.mark.asyncio async def test_vessel_collector_writes_raw_observations_only(monkeypatch): collector = VesselAISCollector() collector.update_progress = AsyncMock() record_observation = AsyncMock() update_health = AsyncMock() broadcast_custom = AsyncMock() monkeypatch.setattr( "app.services.collectors.vessel_ais.record_vessel_ais_observation", record_observation, ) monkeypatch.setattr( "app.services.collectors.vessel_ais.update_ais_source_health", update_health, ) monkeypatch.setattr( "app.services.collectors.vessel_ais.broadcaster.broadcast_custom", broadcast_custom, ) class _Session: def __init__(self): self.added = [] self.committed = False async def get(self, *_args): return None def add(self, item): self.added.append(item) async def execute(self, _stmt): return None async def commit(self): self.committed = True db = _Session() observed_at = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc) saved = await collector._save_data( db, [ { "mmsi": 257123000, "name": "OSLO TRADER", "lat": 59.91, "lon": 10.73, "received_at": observed_at, } ], ) assert saved == 1 assert db.committed is True # BarentsWatch must funnel through the unified AIS pipeline only — no legacy writes. assert not any(isinstance(item, VesselStatic) for item in db.added) assert not any(isinstance(item, VesselPosition) for item in db.added) record_observation.assert_awaited_once() assert record_observation.await_args.kwargs["source"] == "barentswatch_vessels" assert record_observation.await_args.kwargs["normalized_payload"]["mmsi"] == 257123000 update_health.assert_awaited_once() broadcast_custom.assert_awaited_once() assert broadcast_custom.await_args.args[0] == "vessels" assert broadcast_custom.await_args.args[1]["action"] == "upsert" assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "257123000" def test_aisstream_collector_normalizes_position_report(): collector = AISStreamCollector() records = collector.transform( [ { "MessageType": "PositionReport", "MetaData": { "MMSI": 257123000, "ShipName": "OSLO TRADER ", "time_utc": "2026-04-30T12:00:00Z", }, "Message": { "PositionReport": { "Latitude": 59.91, "Longitude": 10.73, "Sog": 12.4, "Cog": 214, "TrueHeading": 215, "NavigationalStatus": 0, } }, } ] ) assert len(records) == 1 assert records[0]["mmsi"] == 257123000 assert records[0]["lat"] == pytest.approx(59.91) assert records[0]["name"] == "OSLO TRADER" assert records[0]["_message_type"] == "PositionReport" def test_aisstream_collector_maps_ship_static_type_name(): collector = AISStreamCollector() records = collector.transform( [ { "MessageType": "ShipStaticData", "MetaData": { "MMSI": 257123000, "time_utc": "2026-04-30T12:00:00Z", }, "Message": { "ShipStaticData": { "Name": "OSLO TRADER", "Type": 79, "CallSign": "LAAB", } }, } ] ) assert len(records) == 1 assert records[0]["vessel_type"] == 79 assert records[0]["vessel_type_name"] == "Cargo" @pytest.mark.asyncio async def test_aisstream_collector_writes_only_raw_observations(monkeypatch): collector = AISStreamCollector() collector.update_progress = AsyncMock() record_observation = AsyncMock(return_value=object()) update_health = AsyncMock() monkeypatch.setattr( "app.services.collectors.aisstream.record_vessel_ais_observation", record_observation, ) monkeypatch.setattr( "app.services.collectors.aisstream.update_ais_source_health", update_health, ) class _Session: def __init__(self): self.added = [] self.committed = False def add(self, item): self.added.append(item) async def commit(self): self.committed = True db = _Session() saved = await collector._save_data( db, [ { "mmsi": 257123000, "lat": 59.91, "lon": 10.73, "received_at": datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc), "_message_type": "PositionReport", } ], ) assert saved == 1 assert db.added == [] assert db.committed is True record_observation.assert_awaited_once() assert record_observation.await_args.kwargs["source"] == "aisstream_vessels" update_health.assert_awaited_once() @pytest.mark.asyncio async def test_aisstream_stream_record_broadcasts_vessel_delta(monkeypatch): collector = AISStreamCollector() record_observation = AsyncMock(return_value=object()) update_health = AsyncMock() broadcast_custom = AsyncMock() monkeypatch.setattr( "app.services.collectors.aisstream.record_vessel_ais_observation", record_observation, ) monkeypatch.setattr( "app.services.collectors.aisstream.update_ais_source_health", update_health, ) monkeypatch.setattr( "app.services.collectors.aisstream.broadcaster.broadcast_custom", broadcast_custom, ) class _Session: async def commit(self): pass created = await collector._save_stream_record( _Session(), { "mmsi": 257123000, "lat": 59.91, "lon": 10.73, "cog": 214, "received_at": datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc), }, ) assert created is True record_observation.assert_awaited_once() broadcast_custom.assert_awaited_once() assert broadcast_custom.await_args.args[0] == "vessels" assert broadcast_custom.await_args.args[1]["action"] == "upsert" assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "257123000" def test_barentswatch_reads_credentials_from_zshrc(tmp_path): zshrc = tmp_path / ".zshrc" zshrc.write_text( "\n".join( [ "export BARENTSWATCH_CLIENT_ID='client-from-zshrc'", 'export BARENTSWATCH_CLIENT_SECRET="secret-from-zshrc" # local dev credential', ] ), encoding="utf-8", ) values = barentswatch._read_zshrc_env(zshrc) assert values["BARENTSWATCH_CLIENT_ID"] == "client-from-zshrc" assert values["BARENTSWATCH_CLIENT_SECRET"] == "secret-from-zshrc" @pytest.mark.asyncio async def test_barentswatch_resolves_config_from_zshrc(tmp_path, monkeypatch): zshrc = tmp_path / ".zshrc" zshrc.write_text( "\n".join( [ "export BARENTSWATCH_CLIENT_ID=client-from-zshrc", "export BARENTSWATCH_CLIENT_SECRET=secret-from-zshrc", ] ), encoding="utf-8", ) monkeypatch.delenv("BARENTSWATCH_CLIENT_ID", raising=False) monkeypatch.delenv("BARENTSWATCH_CLIENT_SECRET", raising=False) monkeypatch.delenv("BARRENTSWATCH_CLIENT_ID", raising=False) monkeypatch.delenv("BARRENTSWATCH_CLIENT_SECRET", raising=False) monkeypatch.setattr(barentswatch.Path, "home", lambda: tmp_path) config = await barentswatch.resolve_barentswatch_config(None) assert config.client_id == "client-from-zshrc" assert config.client_secret == "secret-from-zshrc" assert config.credential_source == "~/.zshrc" def test_convert_vessels_to_geojson(): position = VesselPosition( mmsi=257123000, lat=59.91, lon=10.73, sog=12.4, cog=214, heading=215, nav_status=0, received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc), ) static = VesselStatic( mmsi=257123000, name="OSLO TRADER", vessel_type=70, vessel_type_name="Cargo", flag="NO", length=185, ) payload = convert_vessels_to_geojson([(position, static)]) assert payload["type"] == "FeatureCollection" assert payload["features"][0]["geometry"]["coordinates"] == [10.73, 59.91] assert payload["features"][0]["properties"]["mmsi"] == 257123000 assert payload["features"][0]["properties"]["vessel_type_name"] == "Cargo" def test_convert_vessels_to_geojson_dedupes_mmsi_rows(): first = VesselPosition( mmsi=257123000, lat=59.91, lon=10.73, received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc), ) duplicate = VesselPosition( mmsi=257123000, lat=60.01, lon=10.83, received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc), ) other = VesselPosition( mmsi=257456000, lat=60.3, lon=5.3, received_at=datetime(2026, 4, 28, 0, 59, tzinfo=timezone.utc), ) payload = convert_vessels_to_geojson( [ (first, VesselStatic(mmsi=257123000, name="OSLO TRADER")), (duplicate, VesselStatic(mmsi=257123000, name="OSLO TRADER DUP")), (other, VesselStatic(mmsi=257456000, name="BERGEN FERRY")), ] ) mmsis = [feature["properties"]["mmsi"] for feature in payload["features"]] assert mmsis == [257123000, 257456000] assert payload["features"][0]["geometry"]["coordinates"] == [10.73, 59.91] @pytest.mark.asyncio async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) monkeypatch.setattr( visualization, "get_aggregated_vessels_snapshot", AsyncMock( return_value=[ { "mmsi": 1, "lat": 59.9, "lon": 10.7, "received_at": now, "name": "Cargo Ship", "vessel_type": 70, "vessel_type_name": "Cargo", }, { "mmsi": 2, "lat": 60.3, "lon": 5.3, "received_at": now - timedelta(minutes=1), "name": "Passenger Ship", "vessel_type": 60, "vessel_type_name": "Passenger", }, ] ), ) async def override_get_db(): yield object() app.dependency_overrides[get_db] = override_get_db transport = ASGITransport(app=app) try: async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get( "/api/v1/vessels/snapshot", params={"bbox": "0,50,20,70", "zoom": 12, "type": "cargo", "limit": 1000}, ) assert response.status_code == 200 data = response.json() assert data["count"] == 1 assert data["features"][0]["properties"]["name"] == "Cargo Ship" assert data["stats"]["by_type"]["Cargo"] == 1 finally: app.dependency_overrides.clear() @pytest.mark.asyncio async def test_legacy_vessels_geojson_route_is_not_registered(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/visualization/geo/vessels") assert response.status_code == 404 @pytest.mark.asyncio async def test_vessel_snapshot_requires_bbox(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/vessels/snapshot", params={"zoom": 12}) assert response.status_code == 400 assert response.json()["detail"] == "bbox is required" @pytest.mark.asyncio async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) captured = {} async def fake_get_aggregated_vessels_snapshot(db, *, bbox, limit, observed_since): captured["bbox"] = bbox captured["limit"] = limit captured["observed_since"] = observed_since return [ { "mmsi": 1, "lat": 59.9, "lon": 10.7, "received_at": now, "name": "Cargo Ship", "vessel_type": 70, "vessel_type_name": "Cargo", }, { "mmsi": 2, "lat": 60.3, "lon": 5.3, "received_at": now, "name": "Passenger Ship", "vessel_type": 60, "vessel_type_name": "Passenger", }, ] monkeypatch.setattr( visualization, "get_aggregated_vessels_snapshot", fake_get_aggregated_vessels_snapshot, ) class _Result: def all(self): return [] class _FakeSession: async def execute(self, _query): return _Result() async def override_get_db(): yield _FakeSession() app.dependency_overrides[get_db] = override_get_db transport = ASGITransport(app=app) try: async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get( "/api/v1/vessels/snapshot", params={ "bbox": "10,59,11,60", "zoom": 12, "type": "cargo", "limit": 5000, "since_minutes": 30, }, ) assert response.status_code == 200 data = response.json() assert data["count"] == 1 assert data["features"][0]["properties"]["name"] == "Cargo Ship" assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0) assert captured["limit"] == 5000 assert data["diagnostics"]["bbox_applied"] is True assert data["diagnostics"]["legacy_feature_count"] == 0 assert data["diagnostics"]["legacy_backfilled_mmsi"] == 0 finally: app.dependency_overrides.clear() @pytest.mark.asyncio async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) monkeypatch.setattr( visualization, "get_aggregated_vessels_snapshot", AsyncMock(return_value=[]), ) monkeypatch.setattr( visualization, "_load_legacy_vessel_snapshot_features", AsyncMock( return_value=[ { "type": "Feature", "id": 257123000, "geometry": {"type": "Point", "coordinates": [10.73, 59.91]}, "properties": { "mmsi": 257123000, "name": "OSLO TRADER", "vessel_type": 70, "vessel_type_name": "Cargo", "received_at": now.isoformat(), }, } ] ), ) result = await visualization.build_vessel_snapshot_response( object(), bbox=(10.0, 59.0, 11.0, 60.0), zoom=12, type_filter=None, limit=1000, since_minutes=60, ) assert result["count"] == 1 assert result["features"][0]["properties"]["name"] == "OSLO TRADER" assert result["diagnostics"]["raw_feature_count"] == 0 assert result["diagnostics"]["legacy_feature_count"] == 1 assert result["diagnostics"]["legacy_backfilled_mmsi"] == 1 assert result["diagnostics"]["legacy_fallback_used"] is True @pytest.mark.asyncio async def test_vessel_name_fallbacks_reports_mmsi_display_names(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) monkeypatch.setattr( visualization, "get_aggregated_vessels", AsyncMock( return_value=[ { "mmsi": 257123000, "lat": 59.9, "lon": 10.7, "received_at": now, "name": "MMSI 257123000", "vessel_type_name": "Other", "source_summary": { "aisstream_vessels": { "latest_observed_at": now, "message_types": ["PositionReport"], } }, } ] ), ) class _Result: def all(self): return [] class _FakeSession: async def execute(self, _query): return _Result() async def override_get_db(): yield _FakeSession() app.dependency_overrides[get_db] = override_get_db transport = ASGITransport(app=app) try: async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/visualization/vessels/name-fallbacks") assert response.status_code == 200 data = response.json() assert data["count"] == 1 assert data["items"][0]["mmsi"] == "257123000" assert data["items"][0]["message_types"] == ["PositionReport"] finally: app.dependency_overrides.clear()