release: bump version to 0.48.0
This commit is contained in:
155
backend/tests/test_vessel_enrichment.py
Normal file
155
backend/tests/test_vessel_enrichment.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests for v5 enrichment + conflict promote-to-rule."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation
|
||||
from app.models.vessel_enrichment import VesselMediaEnrichment, VesselProfileEnrichment
|
||||
from app.services.vessel_ais_aggregation import aggregate_vessel_observations
|
||||
from app.services.vessel_enrichment import (
|
||||
_apply_upsert,
|
||||
get_vessel_enrichment_bundle,
|
||||
)
|
||||
|
||||
|
||||
class _StoreSession:
|
||||
"""Minimal AsyncSession stand-in that tracks mmsi-keyed enrichment + a strategy."""
|
||||
|
||||
def __init__(self, *, profile=None, media=None, conflicts=None):
|
||||
self.profile = profile
|
||||
self.media = media
|
||||
self.conflicts = list(conflicts or [])
|
||||
self.added: list = []
|
||||
self.committed = False
|
||||
|
||||
async def get(self, model, key):
|
||||
if model is VesselProfileEnrichment:
|
||||
return self.profile if self.profile and self.profile.mmsi == key else None
|
||||
if model is VesselMediaEnrichment:
|
||||
return self.media if self.media and self.media.mmsi == key else None
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrichment_bundle_filters_expired_records():
|
||||
now = datetime.now(timezone.utc)
|
||||
fresh = VesselProfileEnrichment(
|
||||
mmsi=257123000,
|
||||
source="local_cache",
|
||||
payload={"vessel_subtype": "Container"},
|
||||
fetched_at=now - timedelta(hours=1),
|
||||
expires_at=now + timedelta(days=7),
|
||||
confidence=0.9,
|
||||
)
|
||||
expired_media = VesselMediaEnrichment(
|
||||
mmsi=257123000,
|
||||
source="vesselfinder",
|
||||
payload={"images": ["https://example.com/a.jpg"]},
|
||||
fetched_at=now - timedelta(days=30),
|
||||
expires_at=now - timedelta(days=1),
|
||||
)
|
||||
db = _StoreSession(profile=fresh, media=expired_media)
|
||||
|
||||
bundle = await get_vessel_enrichment_bundle(db, 257123000)
|
||||
|
||||
assert bundle["profile"]["payload"]["vessel_subtype"] == "Container"
|
||||
assert bundle["media"] is None
|
||||
|
||||
|
||||
def test_apply_upsert_preserves_payload_and_metadata():
|
||||
record = VesselProfileEnrichment(mmsi=257123000)
|
||||
out = _apply_upsert(
|
||||
record,
|
||||
{
|
||||
"source": "vesselfinder",
|
||||
"payload": {"vessel_subtype": "Container", "operator": "Maersk"},
|
||||
"expires_at": "2026-12-31T00:00:00Z",
|
||||
"confidence": 0.85,
|
||||
"reference_url": "https://www.vesselfinder.com/vessels/257123000",
|
||||
},
|
||||
)
|
||||
assert out["payload"]["operator"] == "Maersk"
|
||||
assert out["confidence"] == 0.85
|
||||
assert record.reference_url == "https://www.vesselfinder.com/vessels/257123000"
|
||||
assert record.expires_at is not None
|
||||
assert record.expires_at.year == 2026
|
||||
|
||||
|
||||
def _obs(*, source: str, mmsi: int, observed_at, **payload) -> AISRawObservation:
|
||||
payload = {"mmsi": mmsi, "lat": 60.0, "lon": 5.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=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_promoted_rule_wins_during_aggregation():
|
||||
"""Simulate the strategy that conflict-promote-to-rule writes."""
|
||||
now = datetime.now(timezone.utc)
|
||||
obs_a = _obs(
|
||||
source="aisstream_vessels",
|
||||
mmsi=257111000,
|
||||
observed_at=now,
|
||||
name="STREAM NAME",
|
||||
vessel_type_name="Cargo",
|
||||
)
|
||||
obs_b = _obs(
|
||||
source="barentswatch_vessels",
|
||||
mmsi=257111000,
|
||||
observed_at=now - timedelta(seconds=1),
|
||||
name="REST NAME",
|
||||
vessel_type_name="Cargo",
|
||||
)
|
||||
promoted_strategy = {
|
||||
"version": 99,
|
||||
"vessel_ais": {
|
||||
"source_priority": [],
|
||||
"field_rules": {
|
||||
"name": {"mode": "source_priority", "source_priority": ["barentswatch_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=promoted_strategy,
|
||||
)
|
||||
assert vessels[0]["name"] == "REST NAME"
|
||||
assert vessels[0]["selected_reasons"]["name"] == "source_priority"
|
||||
assert vessels[0]["aggregation_strategy_version"] == 99
|
||||
|
||||
|
||||
def test_conflict_record_holds_selected_source():
|
||||
"""Sanity: the promote-to-rule API reads selected_source from this column."""
|
||||
record = AISConflictRecord(
|
||||
target_schema="vessel_ais",
|
||||
entity_key="257111000",
|
||||
field="name",
|
||||
candidates={"a": "X", "b": "Y"},
|
||||
selected_source="barentswatch_vessels",
|
||||
selected_value="Y",
|
||||
selected_reason="delivery_mode_priority",
|
||||
)
|
||||
serialized = record.to_dict()
|
||||
assert serialized["selected_source"] == "barentswatch_vessels"
|
||||
assert serialized["field"] == "name"
|
||||
Reference in New Issue
Block a user