fix: R02 activity API contract — PaginatedActivity + all consumers (#1788956171115)

This commit is contained in:
ik
2026-09-09 20:58:42 +07:00
parent a37f9c4696
commit d962ba2f90
9 changed files with 85 additions and 45 deletions
+12 -8
View File
@@ -45,11 +45,15 @@ def test_activity_filters_and_explains_score() -> None:
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["catches"] == 3
assert payload[0]["unique_players"] == 3
assert "3 свежих улова" in payload[0]["explanation"]
assert payload[0]["sources"] == ["manual-import"]
assert "items" in payload
assert payload["total"] == 1
assert payload["limit"] == 20
assert payload["offset"] == 0
assert len(payload["items"]) == 1
assert payload["items"][0]["catches"] == 3
assert payload["items"][0]["unique_players"] == 3
assert "3 свежих улова" in payload["items"][0]["explanation"]
assert payload["items"][0]["sources"] == ["manual-import"]
def test_invalid_period_is_rejected() -> None:
@@ -157,7 +161,7 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
spot_id = client.get("/api/v1/activity").json()["items"][0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}")
catches = client.get(f"/api/v1/spots/{spot_id}/catches")
assert detail.status_code == 200
@@ -206,7 +210,7 @@ def test_user_report_requires_moderation_before_activity() -> None:
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
assert approved.status_code == 200
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity)
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity["items"])
def test_admin_requires_token() -> None:
@@ -267,7 +271,7 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
assert signal["source_system"] == "rf4db"
assert signal["quality"] == "incomplete"
assert signal["missing_fields"] == ["вес"]
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json())
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
headers = {"Authorization": "Bearer change-me-in-production"}
mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
+5 -4
View File
@@ -3,12 +3,12 @@ from pydantic import ValidationError
from datetime import datetime, timedelta, timezone
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, oldest_site_source, retry_delay
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
from app.config import Settings
def test_all_authorized_sources_are_scheduled() -> None:
assert set(configured_sources()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
assert set(_static_registry()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
def test_community_interval_cannot_be_less_than_30_minutes() -> None:
@@ -25,6 +25,7 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
now = datetime.now(timezone.utc)
assert oldest_site_source("rf4stat-fishing", {}) == "rf4stat-fishing"
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
assert oldest_site_source("rf4stat-fishing", {}, all_keys) == "rf4stat-fishing"
latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)}
assert oldest_site_source("rf4stat-fishing", latest) == "rf4stat-post"
assert oldest_site_source("rf4stat-fishing", latest, all_keys) == "rf4stat-post"
+26 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
@@ -17,11 +18,34 @@ def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
Base.metadata.create_all(engine)
with Session(engine) as db:
for _ in range(5):
_check_rate_limit("203.0.113.42", db)
mock_request = MagicMock()
mock_request.client.host = "203.0.113.42"
mock_request.headers.get.return_value = None
_check_rate_limit(mock_request, db)
with pytest.raises(HTTPException) as blocked:
_check_rate_limit("203.0.113.42", db)
mock_request = MagicMock()
mock_request.client.host = "203.0.113.42"
mock_request.headers.get.return_value = None
_check_rate_limit(mock_request, db)
assert blocked.value.status_code == 429
attempts = list(db.scalars(select(SubmissionAttempt)))
assert len(attempts) == 5
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
def test_rate_limit_uses_forwarded_for_header() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
mock_real = MagicMock()
mock_real.client.host = "10.0.0.1"
mock_real.headers.get.return_value = "198.51.100.10"
for _ in range(5):
_check_rate_limit(mock_real, db)
with pytest.raises(HTTPException) as blocked:
mock_other = MagicMock()
mock_other.client.host = "10.0.0.2"
mock_other.headers.get.return_value = "198.51.100.10"
_check_rate_limit(mock_other, db)
assert blocked.value.status_code == 429
+5 -5
View File
@@ -28,11 +28,11 @@ def test_optional_import_does_not_block_dependencies() -> None:
session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
)
assert ready is True
assert components == {
"postgresql": {"status": "ready"},
"minio": {"status": "ready"},
"official_import": {"status": "optional", "last_run_status": None},
}
assert components["postgresql"]["status"] == "ready"
assert components["minio"]["status"] == "ready"
assert components["official_import"]["status"] == "optional"
assert components["official_import"]["last_run_status"] is None
assert "community_scheduler" in components
def test_required_import_must_be_recent_and_successful() -> None: