Files
rf4-spotter/apps/api/tests/test_api.py
T
ik 4f0c2d23de
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
feat: preserve gear components through catch imports
2026-09-20 18:10:41 +07:00

550 lines
30 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete, 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, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.routers import admin as admin_router
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 "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_waterbody_catalog_exposes_nullable_source_provenance() -> None:
response = client.get("/api/v1/waterbodies")
assert response.status_code == 200
item = next(row for row in response.json() if row["slug"] == "test-lake")
assert item["source_system"] is None
assert item["source_external_id"] is None
assert item["source_url"] is None
assert item["description"] is None
assert item["source_checked_at"] is None
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_published_media_catalog_and_content_addressed_file() -> None:
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
assert catalog.status_code == 200
assert catalog.json()
item = catalog.json()[0]
image = client.get(item["image_url"])
assert image.status_code == 200
assert image.headers["content-type"].startswith("image/")
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
variant = client.get(item["variants"][0]["url"])
assert variant.status_code == 200
assert variant.headers["content-type"].startswith("image/")
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
def test_review_queue_filters_before_pagination() -> None:
with Session(engine) as db:
stage_observations(db, [{
"source_system": "rf4map", "source_external_id": f"queue-{i}",
"source_url": f"https://rf4map.ru/points/queue-{i}",
"fish": "Queue fish", "waterbody": "Queue water",
} for i in range(3)])
rows = list(db.scalars(select(ExternalObservation).where(ExternalObservation.source_system == "rf4map")))
for i, row in enumerate(rows):
row.status = "published" if i == 0 else "ready"
row.last_seen_at = datetime.now(timezone.utc) - timedelta(minutes=i)
db.commit()
try:
headers = {"Authorization": "Bearer change-me-in-production"}
url = "/api/v1/admin/external-observations?status=review&source_system=rf4map&limit=1"
first = client.get(url, headers=headers).json()
second = client.get(url + "&offset=1", headers=headers).json()
assert len(first) == len(second) == 1
assert first[0]["status"] == second[0]["status"] == "ready"
assert first[0]["id"] != second[0]["id"]
finally:
for row in rows:
db.delete(row)
db.commit()
def test_timeline_includes_more_than_catch_page_and_sitemap_includes_old_spots() -> None:
with Session(engine) as db:
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
water = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
spot = Spot(waterbody=water, x=901, y=902)
db.add(spot)
db.flush()
spot_id = spot.id
reports = [CatchReport(fish=fish, waterbody=water, spot=spot, weight_g=1000,
reported_at=datetime.now(timezone.utc) - timedelta(hours=1 if i < 60 else 100),
source_type=SourceType.manual_import, source_confidence=70,
moderation_status=ModerationStatus.approved) for i in range(61)]
db.add_all(reports)
db.commit()
try:
result = client.get(f"/api/v1/spots/{spot_id}/timeline")
assert result.status_code == 200
assert sum(row["count"] for row in result.json()) == 60
assert len(client.get(f"/api/v1/spots/{spot_id}/catches").json()) == 50
for report in reports:
report.reported_at = datetime.now(timezone.utc) - timedelta(days=10)
db.commit()
assert "/spots/test-lake-901x902" in client.get("/api/v1/public-spot-pages").json()
finally:
for report in reports:
db.delete(report)
db.flush()
db.delete(spot)
db.commit()
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
assert client.get("/api/v1/admin/external-observations/00000000-0000-0000-0000-000000000000/alias-suggestions").status_code == 401
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_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() -> None:
assert client.get("/api/v1/admin/source-status").status_code == 401
response = client.get("/api/v1/admin/source-status", headers={"Authorization": "Bearer change-me-in-production"})
assert response.status_code == 200
assert response.json()
assert all({"status", "cooldown_seconds", "recent_failures_24h", "backoff_recommended"} <= set(item) for item in response.json())
assert all({"source_system", "name", "last_started_at", "last_success_at", "next_allowed_at"} <= set(item) for item in response.json())
assert all("error_summary" not in item and "base_url" not in item for item in response.json())
def test_admin_media_review_requires_auth() -> None:
assert client.get("/api/v1/admin/media/catalog").status_code == 401
response = client.get("/api/v1/admin/media/catalog?status=approved&limit=2", headers={"Authorization": "Bearer change-me-in-production"})
assert response.status_code == 200
assert len(response.json()) <= 2
if response.json():
assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0])
assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"])
def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> None:
assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2})
publish = client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": "visual review complete"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert publish.status_code == 200
assert publish.json() == {"published": 2, "retained_fallbacks": 2}
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"})
rollback = client.post(
"/api/v1/admin/media/upgrades/rollback",
json={"asset_url": "https://example.test/a", "note": "fallback is preferred"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert rollback.status_code == 200
assert rollback.json()["rolled_back"] == "https://example.test/a"
assert client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": ""},
headers={"Authorization": "Bearer change-me-in-production"},
).status_code == 422
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
assert client.get("/api/v1/admin/moderation-history").status_code == 401
history = client.get("/api/v1/admin/moderation-history", headers={"Authorization": "Bearer change-me-in-production"})
assert history.status_code == 200
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
assert forbidden not in history.text.lower()
export = client.get("/api/v1/admin/moderation-history-export", headers={"Authorization": "Bearer change-me-in-production"})
assert export.status_code == 200
assert export.headers["Content-Disposition"] == "attachment; filename=rf4spotter-moderation-history.json"
assert set(export.json()) == {"generated_at", "count", "events"}
for forbidden in ("entity_id", "moderator", "reason", "player_name", "source_url", "raw_payload"):
assert forbidden not in export.text.lower()
def test_spot_detail_and_catches() -> None:
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
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
payload = response.json()
assert "items" in payload
assert payload["total"] == 0
assert payload["limit"] == 50
assert payload["offset"] == 0
assert payload["items"] == []
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()
try:
response = client.get("/api/v1/records?category=wanted&limit=1")
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1 # only "wanted" matches
assert payload["limit"] == 1
assert payload["offset"] == 0
assert len(payload["items"]) == 1
assert payload["items"][0]["category"] == "wanted"
finally:
# Cleanup added records
db.execute(delete(CatchReport).where(
CatchReport.source_type == SourceType.official_record,
CatchReport.raw_payload["category"].as_string().in_(["other", "wanted"]),
))
db.commit()
def test_records_pagination_returns_correct_total_and_offset() -> 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)
# Add exactly 5 official records with unique weights
for index in range(5):
db.add(CatchReport(fish=fish, waterbody=waterbody, weight_g=70000 + index * 100, caught_at=now - timedelta(days=index), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved))
db.commit()
try:
# Page 1: limit=2, offset=0
response1 = client.get("/api/v1/records?limit=2&offset=0")
assert response1.status_code == 200
p1 = response1.json()
assert p1["total"] >= 5
assert p1["limit"] == 2
assert p1["offset"] == 0
assert len(p1["items"]) == 2
# Verify first item has our newest caught_at (index=0, weight=70000)
assert p1["items"][0]["weight_g"] == 70000
# Page 2: limit=2, offset=2
response2 = client.get("/api/v1/records?limit=2&offset=2")
assert response2.status_code == 200
p2 = response2.json()
assert p2["total"] == p1["total"] # total must be consistent
assert p2["limit"] == 2
assert p2["offset"] == 2
assert len(p2["items"]) == 2
# Page 3: limit=2, offset=4
response3 = client.get("/api/v1/records?limit=2&offset=4")
assert response3.status_code == 200
p3 = response3.json()
assert p3["total"] == p1["total"]
assert p3["offset"] == 4
# Last page should have remaining items
assert len(p3["items"]) <= 2
# Page 4: offset=total — past total, empty
response4 = client.get(f"/api/v1/records?limit=2&offset={p1['total']}")
assert response4.status_code == 200
p4 = response4.json()
assert p4["total"] == p1["total"]
assert p4["items"] == []
finally:
# Cleanup added records
db.execute(delete(CatchReport).where(
CatchReport.source_type == SourceType.official_record,
CatchReport.weight_g >= 70000,
))
db.commit()
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": "Новая приманка", "rig_type": "Спиннинг", "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"]
with Session(engine) as db:
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
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", "expected_version": 0})
assert approved.status_code == 200
stale = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "rejected", "reason": "stale tab", "expected_version": 0})
assert stale.status_code == 409
assert "reload" in stale.json()["detail"]
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["items"])
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, json={"expected_version": 0})
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", "expected_version": 0},
)
assert mapped.status_code == 200
assert mapped.json()["status"] == "ready"
published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1})
assert published.status_code == 200
assert published.json()["status"] == "published"
repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1})
assert repeated.status_code == 409
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"] == ["вес"]
headers = {"Authorization": "Bearer change-me-in-production"}
incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers)
assert any(item["id"] == str(observation_id) for item in incomplete.json())
provenance = next(item for item in incomplete.json() if item["id"] == str(observation_id))
assert provenance["missing_fields"] == ["weight_g"]
assert provenance["first_seen_at"] and provenance["last_seen_at"]
assert set(provenance["source_payload"]) <= {"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed", "player_name", "published_at", "region", "category"}
complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers)
assert all(item["id"] != str(observation_id) for item in complete.json())
prioritized = client.get("/api/v1/admin/external-observations?status=review&order=risk", headers=headers)
assert prioritized.status_code == 200
assert prioritized.json()[0]["weight_g"] is None
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "expected_version": 0},
)
assert mapped.json()["status"] == "mapped"
assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1}).status_code == 409
rejected = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/reject", headers=headers,
json={"reason": "weight is absent", "expected_version": 1},
)
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.routers.admin.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.routers.admin.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.routers.submissions.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.routers.admin.delete_screenshot", deleted_keys.append)
headers = {"Authorization": "Bearer change-me-in-production"}
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}?expected_version=0", 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']}?expected_version=0", headers=headers).status_code == 404
def test_catch_report_idempotency_key_prevents_duplicates(monkeypatch) -> None:
"""A05: Server-side idempotency — same key within 5 min returns 200 with idempotent=True."""
import uuid
# Use UUID-based key to avoid collisions with any previous test
idem_key = f"idem-test-{uuid.uuid4().hex[:16]}"
headers = {"Idempotency-Key": idem_key}
payload = {"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 99, "y": 100, "weight_g": 7700}
# First request — creates report
first = client.post("/api/v1/catch-reports", json=payload, headers=headers)
assert first.status_code == 201
assert first.json()["idempotent"] is False
report_id = first.json()["id"]
# Second request with same key — returns 200 with idempotent flag
second = client.post("/api/v1/catch-reports", json=payload, headers=headers)
assert second.status_code == 200, f"Expected 200, got {second.status_code}. Response: {second.json()}"
assert second.json()["idempotent"] is True
assert second.json()["id"] == report_id
assert second.json()["screenshot_upload_token"] == first.json()["screenshot_upload_token"]
changed = dict(payload, weight_g=7800)
conflict = client.post("/api/v1/catch-reports", json=changed, headers=headers)
assert conflict.status_code == 409