release: bump version to 0.52.0

This commit is contained in:
linkong
2026-05-12 17:15:02 +08:00
parent b15d097b9c
commit b87cb310fd
70 changed files with 5589 additions and 2187 deletions

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

View 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

View 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)

View File

@@ -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(

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

View File

@@ -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

View File

@@ -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,
}
]