feat: lock concurrent moderation decisions
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-13 09:26:18 +07:00
parent cbd854d933
commit 0abe1a2bb4
10 changed files with 147 additions and 31 deletions
+14 -11
View File
@@ -286,8 +286,11 @@ def test_user_report_requires_moderation_before_activity() -> None:
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"})
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"])
@@ -312,19 +315,19 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat
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)
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"},
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)
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)
assert repeated.json()["catch_report_id"] == published.json()["catch_report_id"]
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)
@@ -365,13 +368,13 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
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"},
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).status_code == 409
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"},
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())
@@ -430,7 +433,7 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
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)
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:
@@ -442,7 +445,7 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
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
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: