from __future__ import annotations from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base, get_session from app.community_importer import stage_observations from app.importer import ImportAlreadyRunning from app.main import app from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(engine) def override_session(): with Session(engine) as session: yield session app.dependency_overrides[get_session] = override_session client = TestClient(app) def setup_module() -> None: with Session(engine) as db: waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1) fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000) bait = Bait(name="Тестовая приманка", normalized_name="тестовая приманка", kind=BaitKind.lure) spot = Spot(waterbody=waterbody, x=10, y=20, description="Тестовая точка") db.add_all([waterbody, fish, bait, spot]) now = datetime.now(timezone.utc) for index in range(3): db.add(CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=3000 + index * 1000, fishing_method="spinning", reported_at=now - timedelta(hours=index), caught_at=now - timedelta(hours=index), player_name=f"Player {index}", source_type=SourceType.manual_import, source_confidence=90, moderation_status=ModerationStatus.approved)) db.commit() 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"] def test_invalid_period_is_rejected() -> None: assert client.get("/api/v1/activity?hours=13").status_code == 422 assert client.get("/api/v1/activity?sort=unknown").status_code == 422 def test_list_pagination_and_filter_validation() -> None: assert client.get("/api/v1/fishes?limit=0").status_code == 422 assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200 headers = {"Authorization": "Bearer change-me-in-production"} assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422 assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422 assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422 def test_public_source_status_hides_internal_details() -> None: with Session(engine) as db: if db.get(DataSource, "rf4db") is None: db.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)) db.commit() response = client.get("/api/v1/source-status") assert response.status_code == 200 assert response.json() assert all("error_summary" not in item and "source_url" not in item for item in response.json()) def test_liveness_does_not_probe_dependencies() -> None: response = client.get("/health?token=must-not-be-logged") assert response.json() == {"status": "ok"} assert len(response.headers["X-Request-ID"]) == 32 assert response.headers["X-Frame-Options"] == "DENY" assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin" def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None: assert client.get("/api/v1/admin/diagnostics").status_code == 401 response = client.get("/api/v1/admin/diagnostics", headers={"Authorization": "Bearer change-me-in-production"}) assert response.status_code == 200 payload = response.json() assert payload["build"] == {"version": "0.1.0", "revision": "dev", "environment": "development"} assert set(payload) == {"generated_at", "build", "counts"} assert response.headers["Content-Disposition"] == "attachment; filename=rf4spotter-diagnostics.json" serialized = response.text.lower() for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"): assert forbidden not in serialized def test_spot_detail_and_catches() -> None: spot_id = client.get("/api/v1/activity").json()[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 assert detail.json()["catches_24h"] == 3 assert catches.status_code == 200 assert len(catches.json()) == 3 assert catches.json()[0]["source_system"] == "manual-import" resolved = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20") assert resolved.status_code == 200 assert resolved.json()["id"] == spot_id def test_records_list_is_empty_before_import() -> None: response = client.get("/api/v1/records") assert response.status_code == 200 assert response.json() == [] def test_record_category_filter_is_applied_before_pagination() -> None: with Session(engine) as db: fish = db.scalar(select(Fish).where(Fish.slug == "pike")) waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake")) now = datetime.now(timezone.utc) - timedelta(days=30) db.add_all([ CatchReport(fish=fish, waterbody=waterbody, weight_g=9000, caught_at=now, reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "other"}), CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}), ]) db.commit() response = client.get("/api/v1/records?category=wanted&limit=1") assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["category"] == "wanted" def test_user_report_requires_moderation_before_activity() -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) assert created.status_code == 201 assert created.headers["Cache-Control"] == "no-store" assert created.json()["moderation_status"] == "pending" report_id = created.json()["id"] headers = {"Authorization": "Bearer change-me-in-production"} pending = client.get("/api/v1/admin/catch-reports", headers=headers) assert pending.status_code == 200 assert pending.headers["Cache-Control"] == "no-store" assert any(item["id"] == report_id for item in pending.json()) 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) def test_admin_requires_token() -> None: assert client.get("/api/v1/admin/catch-reports").status_code == 401 assert client.get("/api/v1/admin/imports").status_code == 401 assert client.post("/api/v1/admin/imports/official-records").status_code == 401 assert client.get("/api/v1/admin/external-observations").status_code == 401 def test_external_observation_requires_mapping_and_complete_data_before_publication() -> None: with Session(engine) as db: stage_observations(db, [{ "source_system": "rf4db", "source_external_id": "review-complete", "source_url": "https://rf4db.com/catches/review-complete", "fish": "Pike external", "fish_external_id": "fish-1", "waterbody": "Lake external", "waterbody_external_id": "lake-1", "x": 31, "y": 41, "weight_g": 6200, "bait": "Тестовая приманка", "player_name": "External Player", }]) observation_id = db.scalar(select(ExternalObservation.id).where( ExternalObservation.source_external_id == "review-complete" )) headers = {"Authorization": "Bearer change-me-in-production"} premature = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers) assert premature.status_code == 409 mapped = client.patch( f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, json={"fish_slug": "pike", "waterbody_slug": "test-lake", "note": "verified fixture"}, ) assert mapped.status_code == 200 assert mapped.json()["status"] == "ready" published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers) assert published.status_code == 200 assert published.json()["status"] == "published" repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers) assert repeated.json()["catch_report_id"] == published.json()["catch_report_id"] with Session(engine) as db: observation = db.get(ExternalObservation, observation_id) report = db.get(CatchReport, observation.catch_report_id) aliases = list(db.scalars(select(ExternalEntityAlias).where(ExternalEntityAlias.source_system == "rf4db"))) assert report.moderation_status == ModerationStatus.approved assert report.raw_payload["provenance"]["source_external_id"] == "review-complete" assert {alias.entity_type for alias in aliases} == {"fish", "waterbody"} def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() -> None: with Session(engine) as db: stage_observations(db, [{ "source_system": "rf4db", "source_external_id": "review-incomplete", "source_url": "https://rf4db.com/catches/review-incomplete", "fish": "Pike external", "waterbody": "Lake external", "x": 32, "y": 42, }]) observation_id = db.scalar(select(ExternalObservation.id).where( ExternalObservation.source_external_id == "review-incomplete" )) public = client.get("/api/v1/community-observations") assert public.status_code == 200 signal = next(item for item in public.json() if item["id"] == str(observation_id)) 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()) headers = {"Authorization": "Bearer change-me-in-production"} mapped = client.patch( f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, json={"fish_slug": "pike", "waterbody_slug": "test-lake"}, ) assert mapped.json()["status"] == "mapped" assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers).status_code == 409 rejected = client.patch( f"/api/v1/admin/external-observations/{observation_id}/reject", headers=headers, json={"reason": "weight is absent"}, ) assert rejected.json()["status"] == "rejected" assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json()) def test_admin_can_start_and_list_official_import(monkeypatch) -> None: def fake_import(db: Session, **_: str) -> OfficialRecordImport: run = OfficialRecordImport( started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc), status=ImportStatus.success, source_url="fixture://admin", rows_seen=2, rows_created=2, rows_updated=0, ) db.add(run) db.commit() db.refresh(run) return run monkeypatch.setattr("app.main.import_records", fake_import) headers = {"Authorization": "Bearer change-me-in-production"} started = client.post("/api/v1/admin/imports/official-records", headers=headers) assert started.status_code == 201 assert started.json()["source_url"] == "fixture://admin" listed = client.get("/api/v1/admin/imports?limit=1&offset=0", headers=headers) assert listed.status_code == 200 assert listed.json()[0]["id"] == started.json()["id"] def busy_import(*args, **kwargs): raise ImportAlreadyRunning("official import is already running") monkeypatch.setattr("app.main.import_records", busy_import) conflict = client.post("/api/v1/admin/imports/official-records", headers=headers) assert conflict.status_code == 409 def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json() monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected") upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot" assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401 assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401 response = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}) assert response.status_code == 204 reused = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}) assert reused.status_code == 401 def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 93, "y": 94, "weight_g": 4300, "player_name": "Private Player", "source_url": "https://example.test/private", "comment": "private comment"}).json() with Session(engine) as db: report = db.get(CatchReport, UUID(created["id"])) report.screenshot_key = "reports/private.jpg" db.commit() deleted_keys: list[str] = [] monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append) headers = {"Authorization": "Bearer change-me-in-production"} response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers) assert response.status_code == 204 assert deleted_keys == ["reports/private.jpg"] with Session(engine) as db: report = db.get(CatchReport, UUID(created["id"])) assert report.deleted_at is not None assert report.moderation_status == ModerationStatus.rejected assert report.player_name is None and report.source_url is None assert report.screenshot_key is None and report.raw_payload is None event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first() assert event is not None assert event.reason == "user report deleted and anonymized" assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers).status_code == 404