release: bump version to 0.52.0
This commit is contained in:
86
backend/tests/test_datasources_batch.py
Normal file
86
backend/tests/test_datasources_batch.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import datasources as datasources_api
|
||||
from app.models.datasource import DataSource
|
||||
|
||||
|
||||
def make_datasource(
|
||||
datasource_id: int,
|
||||
source: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
module: str = "L4",
|
||||
is_active: bool = True,
|
||||
last_status: str | None = None,
|
||||
last_run_at: datetime | None = None,
|
||||
frequency_minutes: int = 60,
|
||||
) -> DataSource:
|
||||
return DataSource(
|
||||
id=datasource_id,
|
||||
name=name or source,
|
||||
source=source,
|
||||
module=module,
|
||||
priority="P1",
|
||||
frequency_minutes=frequency_minutes,
|
||||
collector_class=source,
|
||||
is_active=is_active,
|
||||
last_status=last_status,
|
||||
last_run_at=last_run_at,
|
||||
)
|
||||
|
||||
|
||||
def test_datasource_product_key_groups_domain_specific_sources():
|
||||
assert datasources_api.datasource_product_key(make_datasource(1, "aisstream_vessels")) == "vessels"
|
||||
assert datasources_api.datasource_product_key(make_datasource(2, "telegeography_cables")) == "cables"
|
||||
assert datasources_api.datasource_product_key(make_datasource(3, "celestrak_tle")) == "satellites"
|
||||
assert datasources_api.datasource_product_key(make_datasource(4, "ris_live_bgp")) == "bgp"
|
||||
|
||||
|
||||
def test_filter_datasources_by_product_status_and_collected_state():
|
||||
vessels = make_datasource(1, "aisstream_vessels", last_status="success")
|
||||
cables = make_datasource(2, "telegeography_cables", last_status="failed")
|
||||
filtered = datasources_api._filter_datasources_in_memory(
|
||||
[vessels, cables],
|
||||
running_tasks={},
|
||||
record_counts={"aisstream_vessels": 12, "telegeography_cables": 0},
|
||||
product="vessels",
|
||||
run_status="success",
|
||||
collected=True,
|
||||
)
|
||||
|
||||
assert filtered == [vessels]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
disabled = make_datasource(1, "aisstream_vessels", is_active=False)
|
||||
not_due = make_datasource(2, "telegeography_cables", last_run_at=now, frequency_minutes=120)
|
||||
due = make_datasource(3, "ris_live_bgp", last_run_at=now - timedelta(hours=2))
|
||||
triggered_sources: list[str] = []
|
||||
|
||||
async def fake_running_tasks(_db, _ids):
|
||||
return {}
|
||||
|
||||
async def fake_latest_task_ids(_db, _ids):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_running_tasks", fake_running_tasks)
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_task_ids", fake_latest_task_ids)
|
||||
monkeypatch.setattr(
|
||||
datasources_api,
|
||||
"run_collector_now",
|
||||
lambda source: triggered_sources.append(source) or True,
|
||||
)
|
||||
|
||||
result = await datasources_api._trigger_datasource_batch(
|
||||
object(),
|
||||
[disabled, not_due, due],
|
||||
force=False,
|
||||
)
|
||||
|
||||
assert [item["source"] for item in result["triggered"]] == ["ris_live_bgp"]
|
||||
assert {item["reason"] for item in result["skipped"]} == {"disabled", "within_frequency_window"}
|
||||
assert triggered_sources == ["ris_live_bgp"]
|
||||
44
backend/tests/test_layers.py
Normal file
44
backend/tests/test_layers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1 import layers
|
||||
|
||||
|
||||
def test_layer_guard_requires_bbox():
|
||||
try:
|
||||
layers._parse_layer_bbox("")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("Expected missing bbox to fail")
|
||||
|
||||
|
||||
def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit():
|
||||
geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [121.0, 31.0]},
|
||||
"properties": {"id": "inside"},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [10.0, 10.0]},
|
||||
"properties": {"id": "outside"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = layers._guard_geojson_layer(
|
||||
geojson,
|
||||
bbox=(120.0, 30.0, 122.0, 32.0),
|
||||
zoom=2,
|
||||
limit=6000,
|
||||
)
|
||||
|
||||
assert result["returned_count"] == 1
|
||||
assert result["visible_count"] == 1
|
||||
assert result["features"][0]["properties"]["id"] == "inside"
|
||||
assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT
|
||||
assert result["diagnostics"]["limit_clamped"] is True
|
||||
assert result["diagnostics"]["degraded"] is True
|
||||
106
backend/tests/test_otp_service.py
Normal file
106
backend/tests/test_otp_service.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Unit tests for app.services.otp using an in-memory Redis fake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import otp
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal subset of redis-py used by services.otp."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, tuple[Any, float | None]] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return True
|
||||
_, expires = item
|
||||
if expires is not None and expires <= time.time():
|
||||
self._store.pop(key, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: Any, ex: int | None = None) -> None:
|
||||
expires = time.time() + ex if ex else None
|
||||
self._store[key] = (value, expires)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
if self._expired(key):
|
||||
return None
|
||||
return self._store[key][0]
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
if self._expired(key):
|
||||
return -2
|
||||
_, expires = self._store[key]
|
||||
if expires is None:
|
||||
return -1
|
||||
return max(int(expires - time.time()), 0)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._store.pop(key, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis(monkeypatch):
|
||||
fake = FakeRedis()
|
||||
monkeypatch.setattr(otp, "redis_client", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_issue_code_returns_six_digits(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
assert len(code) == 6
|
||||
assert code.isdigit()
|
||||
|
||||
|
||||
def test_verify_code_succeeds_and_consumes(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
with pytest.raises(otp.OtpExpired):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_verify_code_rejects_wrong_code(fake_redis):
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
with pytest.raises(otp.OtpInvalid):
|
||||
otp.verify_code("alice@example.com", "register", "000000")
|
||||
|
||||
|
||||
def test_verify_code_locks_after_max_attempts(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
for _ in range(otp.MAX_ATTEMPTS):
|
||||
with pytest.raises(otp.OtpInvalid):
|
||||
otp.verify_code("alice@example.com", "register", "000000")
|
||||
# After max attempts the next call should raise OtpAttemptsExceeded and clear the code.
|
||||
with pytest.raises(otp.OtpAttemptsExceeded):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
with pytest.raises(otp.OtpExpired):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_issue_code_enforces_resend_cooldown(fake_redis):
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
with pytest.raises(otp.OtpResendRateLimited) as excinfo:
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
assert excinfo.value.retry_after_seconds > 0
|
||||
|
||||
|
||||
def test_issue_code_emails_are_case_insensitive(fake_redis):
|
||||
code = otp.issue_code("Alice@Example.com", "register")
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_purposes_are_isolated(fake_redis):
|
||||
register_code = otp.issue_code("alice@example.com", "register")
|
||||
reset_code = otp.issue_code("alice@example.com", "reset_password")
|
||||
assert register_code != reset_code
|
||||
otp.verify_code("alice@example.com", "register", register_code)
|
||||
# Reset code should still be valid after consuming the register code.
|
||||
otp.verify_code("alice@example.com", "reset_password", reset_code)
|
||||
@@ -5,9 +5,12 @@ import pytest
|
||||
from app.api.v1 import settings as settings_api
|
||||
from app.api.v1.settings import (
|
||||
AIProviderIntegrationUpdate,
|
||||
OCRIntegrationUpdate,
|
||||
_build_ai_provider_payload,
|
||||
_build_ocr_payload,
|
||||
_mask_secret,
|
||||
_normalize_ai_provider_payload,
|
||||
_normalize_ocr_payload,
|
||||
_resolve_provider_api_key,
|
||||
get_runtime_ai_provider_config,
|
||||
)
|
||||
@@ -130,6 +133,43 @@ def test_build_payload_keeps_saved_key_when_preview_submitted():
|
||||
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
|
||||
|
||||
|
||||
def test_normalize_ocr_payload_adds_defaults():
|
||||
payload = _normalize_ocr_payload({})
|
||||
|
||||
assert payload["enabled"] is False
|
||||
assert payload["provider"] == "paddleocr"
|
||||
assert payload["languages"] == ["zh", "en"]
|
||||
assert payload["output_format"] == "markdown"
|
||||
|
||||
|
||||
def test_build_ocr_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"ocr": {
|
||||
"enabled": True,
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:8020",
|
||||
"api_key": "ocr-old-secret",
|
||||
}
|
||||
}
|
||||
update = OCRIntegrationUpdate(
|
||||
enabled=True,
|
||||
provider="custom",
|
||||
base_url="http://localhost:8020",
|
||||
api_key="**************",
|
||||
model="ocr-model",
|
||||
languages=["zh", "en"],
|
||||
timeout_seconds=45,
|
||||
max_file_size_mb=50,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
payload = _build_ocr_payload(current, update)
|
||||
|
||||
assert payload["api_key"] == "ocr-old-secret"
|
||||
assert payload["model"] == "ocr-model"
|
||||
assert payload["output_format"] == "json"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
|
||||
record = SimpleNamespace(
|
||||
|
||||
86
backend/tests/test_settings_smtp.py
Normal file
86
backend/tests/test_settings_smtp.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Unit tests for SMTP settings helpers in app.api.v1.settings."""
|
||||
|
||||
from app.api.v1.settings import (
|
||||
SMTPSettingsUpdate,
|
||||
_build_smtp_payload,
|
||||
_serialize_smtp_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_masks_password_and_reports_configured():
|
||||
serialized = _serialize_smtp_payload(
|
||||
{
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "noreply@example.com",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"from_name": "Planet",
|
||||
"use_tls": False,
|
||||
"use_starttls": True,
|
||||
"timeout_seconds": 20,
|
||||
}
|
||||
)
|
||||
assert serialized["configured"] is True
|
||||
assert serialized["password"]["configured"] is True
|
||||
assert "secret" not in serialized["password"]["preview"]
|
||||
|
||||
|
||||
def test_serialize_marks_unconfigured_when_host_missing():
|
||||
serialized = _serialize_smtp_payload(
|
||||
{
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"from_address": "",
|
||||
}
|
||||
)
|
||||
assert serialized["configured"] is False
|
||||
assert serialized["password"]["configured"] is False
|
||||
|
||||
|
||||
def test_build_payload_preserves_password_when_placeholder_submitted():
|
||||
current = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "noreply@example.com",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"from_name": "Planet",
|
||||
"use_tls": False,
|
||||
"use_starttls": True,
|
||||
"timeout_seconds": 20,
|
||||
}
|
||||
preview = _serialize_smtp_payload(current)["password"]["preview"]
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="noreply@example.com",
|
||||
password=preview,
|
||||
from_address="noreply@example.com",
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == "super-secret"
|
||||
|
||||
|
||||
def test_build_payload_replaces_password_when_new_value_submitted():
|
||||
current = {"password": "old", "host": "", "port": 587, "from_address": ""}
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
password="new-secret",
|
||||
from_address="noreply@example.com",
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == "new-secret"
|
||||
|
||||
|
||||
def test_build_payload_clears_password_when_requested():
|
||||
current = {"password": "old"}
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
from_address="noreply@example.com",
|
||||
clear_password=True,
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == ""
|
||||
@@ -523,22 +523,115 @@ def test_convert_vessels_to_geojson_dedupes_mmsi_rows():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
||||
async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
rows = [
|
||||
(
|
||||
VesselPosition(mmsi=1, lat=59.9, lon=10.7, received_at=now),
|
||||
VesselStatic(mmsi=1, name="Cargo Ship", vessel_type=70, vessel_type_name="Cargo"),
|
||||
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",
|
||||
},
|
||||
]
|
||||
),
|
||||
(
|
||||
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now - timedelta(minutes=1)),
|
||||
VesselStatic(mmsi=2, 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_endpoint_is_gone():
|
||||
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 == 410
|
||||
assert "/api/v1/vessels/snapshot" in response.json()["detail"]
|
||||
|
||||
|
||||
@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 rows
|
||||
return []
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
@@ -552,75 +645,39 @@ async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/visualization/geo/vessels",
|
||||
params={"bbox": "0,50,20,70", "type": "cargo", "limit": 0},
|
||||
"/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 data["stats"]["by_type"]["Cargo"] == 1
|
||||
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_vessels_geojson_merges_raw_and_legacy_sources(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(
|
||||
visualization,
|
||||
"get_aggregated_vessels",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.9,
|
||||
"lon": 10.7,
|
||||
"received_at": now,
|
||||
"name": "AISSTREAM SHIP",
|
||||
"vessel_type_name": "Cargo",
|
||||
"source_summary": {"aisstream_vessels": {"message_types": ["PositionReport"]}},
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
rows = [
|
||||
(
|
||||
VesselPosition(mmsi=1, lat=60.0, lon=10.8, received_at=now),
|
||||
VesselStatic(mmsi=1, name="LEGACY DUP", vessel_type_name="Cargo"),
|
||||
),
|
||||
(
|
||||
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now),
|
||||
VesselStatic(mmsi=2, name="BARENTSWATCH ONLY", vessel_type_name="Passenger"),
|
||||
),
|
||||
]
|
||||
|
||||
class _Result:
|
||||
def all(self):
|
||||
return rows
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _Result()
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
async def test_legacy_vessels_geojson_rejects_even_with_bbox():
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/vessels")
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/visualization/geo/vessels",
|
||||
params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
names = {feature["properties"]["mmsi"]: feature["properties"]["name"] for feature in data["features"]}
|
||||
assert data["count"] == 2
|
||||
assert names == {1: "AISSTREAM SHIP", 2: "BARENTSWATCH ONLY"}
|
||||
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 410
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
import importlib
|
||||
|
||||
from app.core.websocket.manager import ConnectionManager
|
||||
from app.core.websocket.broadcaster import DataBroadcaster
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
@@ -44,3 +46,93 @@ async def test_disconnect_removes_channel_subscriptions():
|
||||
|
||||
assert socket.sent == []
|
||||
assert "dashboard" not in manager.channel_subscriptions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_subscribers_receive_only_matching_bbox_updates():
|
||||
manager = ConnectionManager()
|
||||
oslo_socket = FakeWebSocket()
|
||||
bergen_socket = FakeWebSocket()
|
||||
|
||||
await manager.connect(oslo_socket, "user-1")
|
||||
await manager.connect(bergen_socket, "user-2")
|
||||
manager.subscribe_vessels(
|
||||
oslo_socket,
|
||||
{"bbox": [10, 59, 11, 60], "zoom": 12, "limit": 1000},
|
||||
)
|
||||
manager.subscribe_vessels(
|
||||
bergen_socket,
|
||||
{"bbox": [5, 60, 6, 61], "zoom": 12, "limit": 1000},
|
||||
)
|
||||
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"vessels": [
|
||||
{"mmsi": 1, "lat": 59.9, "lon": 10.7},
|
||||
{"mmsi": 2, "lat": 60.3, "lon": 5.3},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert oslo_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]
|
||||
assert bergen_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 2, "lat": 60.3, "lon": 5.3}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_broadcast_removes_slow_connections():
|
||||
manager = ConnectionManager()
|
||||
|
||||
class BrokenWebSocket(FakeWebSocket):
|
||||
async def send_json(self, message):
|
||||
raise RuntimeError("client is gone")
|
||||
|
||||
socket = BrokenWebSocket()
|
||||
await manager.connect(socket, "user-1")
|
||||
manager.subscribe_vessels(socket, {"bbox": [10, 59, 11, 60], "zoom": 12})
|
||||
|
||||
await manager.broadcast_vessels({"vessels": [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]})
|
||||
|
||||
assert socket not in manager.vessel_subscriptions
|
||||
|
||||
|
||||
def test_vessel_subscription_rejects_large_bbox():
|
||||
manager = ConnectionManager()
|
||||
|
||||
with pytest.raises(ValueError, match="bbox is too large"):
|
||||
manager.subscribe_vessels(FakeWebSocket(), {"bbox": [-180, -90, 180, 90], "zoom": 1})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_broadcast_vessels(payload):
|
||||
sent.append(payload)
|
||||
|
||||
broadcaster_module = importlib.import_module("app.core.websocket.broadcaster")
|
||||
monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels)
|
||||
broadcaster = DataBroadcaster()
|
||||
broadcaster.enqueue_vessel_update(
|
||||
{
|
||||
"source": "aisstream_vessels",
|
||||
"vessels": [
|
||||
{"mmsi": 1, "lat": 59.0, "lon": 10.0},
|
||||
{"mmsi": 1, "lat": 59.1, "lon": 10.1},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
await broadcaster.flush_vessel_updates()
|
||||
|
||||
assert len(sent) == 1
|
||||
assert sent[0]["vessels"] == [
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.1,
|
||||
"lon": 10.1,
|
||||
"source": "aisstream_vessels",
|
||||
"action": "upsert",
|
||||
"created": None,
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user