Implement message reporting endpoint and moderation review queue (US-4)
- POST /api/chat/messages/{message_id}/report for reporting messages with category/reason
- Idempotent duplicate report rejection (409)
- GET /api/moderation/reports for queue listing (restricted to moderators/admins)
- POST /api/moderation/reports/{id}/resolve supporting dismiss, remove, warn, ban actions
- Audit fields (moderator_id, resolved_at, action) recorded on resolution
- Added 9 integration tests in backend/tests/test_chat_reports.py (all passing)
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""Integration tests for message reporting and moderation queue:
|
||||
- POST /api/chat/messages/{message_id}/report
|
||||
- GET /api/moderation/reports
|
||||
- POST /api/moderation/reports/{id}/resolve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return {"headers": headers, "id": me.json()["id"], "username": username}
|
||||
|
||||
|
||||
def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int:
|
||||
resp = client.post(
|
||||
f"/api/chat/dm/{recipient['id']}/messages",
|
||||
headers=sender["headers"],
|
||||
json={"body": text},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
class TestReportMessage:
|
||||
def test_report_message_success(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice1")
|
||||
bob = _register(client, "rep_bob1")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "spam message")
|
||||
|
||||
resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "Contains spam", "category": "spam"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["message_id"] == msg_id
|
||||
assert data["reporter_id"] == bob["id"]
|
||||
assert data["status"] == "open"
|
||||
assert "[spam] Contains spam" in data["reason"]
|
||||
|
||||
def test_duplicate_report_rejected(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice2")
|
||||
bob = _register(client, "rep_bob2")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "bad message")
|
||||
|
||||
resp1 = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "offensive"},
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
|
||||
resp2 = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "offensive again"},
|
||||
)
|
||||
assert resp2.status_code == 409
|
||||
|
||||
def test_report_nonexistent_message_404(self):
|
||||
client = TestClient(app)
|
||||
bob = _register(client, "rep_bob3")
|
||||
|
||||
resp = client.post(
|
||||
"/api/chat/messages/999999/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "ghost"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_report_requires_auth(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestModerationQueue:
|
||||
def test_non_moderator_cannot_access_queue(self):
|
||||
client = TestClient(app)
|
||||
regular_user = _register(client, "rep_regular1")
|
||||
|
||||
resp = client.get("/api/moderation/reports", headers=regular_user["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_moderator_can_list_reports(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice4")
|
||||
bob = _register(client, "rep_bob4")
|
||||
admin = _register(client, "admin_list")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "something to report")
|
||||
client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "needs review"},
|
||||
)
|
||||
|
||||
resp = client.get("/api/moderation/reports", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
reports = resp.json()
|
||||
assert len(reports) >= 1
|
||||
assert any(r["message_id"] == msg_id for r in reports)
|
||||
|
||||
def test_moderator_resolve_dismiss(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice5")
|
||||
bob = _register(client, "rep_bob5")
|
||||
admin = _register(client, "admin_dismiss")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "benign message")
|
||||
rep_resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "false alarm"},
|
||||
)
|
||||
report_id = rep_resp.json()["id"]
|
||||
|
||||
resolve_resp = client.post(
|
||||
f"/api/moderation/reports/{report_id}/resolve",
|
||||
headers=admin["headers"],
|
||||
json={"action": "dismiss", "notes": "No violation found"},
|
||||
)
|
||||
assert resolve_resp.status_code == 200, resolve_resp.text
|
||||
data = resolve_resp.json()
|
||||
assert data["report_id"] == report_id
|
||||
assert data["status"] == "dismissed"
|
||||
assert data["action_taken"] == "dismiss"
|
||||
assert data["moderator_id"] == admin["id"]
|
||||
|
||||
def test_moderator_resolve_remove(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "rep_alice6")
|
||||
bob = _register(client, "rep_bob6")
|
||||
admin = _register(client, "admin_remove")
|
||||
|
||||
msg_id = _send_dm(client, alice, bob, "offensive content")
|
||||
rep_resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=bob["headers"],
|
||||
json={"reason": "hate speech"},
|
||||
)
|
||||
report_id = rep_resp.json()["id"]
|
||||
|
||||
resolve_resp = client.post(
|
||||
f"/api/moderation/reports/{report_id}/resolve",
|
||||
headers=admin["headers"],
|
||||
json={"action": "remove", "notes": "Content removed"},
|
||||
)
|
||||
assert resolve_resp.status_code == 200
|
||||
assert resolve_resp.json()["status"] == "resolved"
|
||||
|
||||
# The message is removed from conversation history
|
||||
hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
|
||||
assert all(m["id"] != msg_id for m in hist.json()["messages"])
|
||||
|
||||
def test_moderator_resolve_ban(self):
|
||||
client = TestClient(app)
|
||||
bad_user = _register(client, "bad_actor")
|
||||
victim = _register(client, "rep_victim")
|
||||
admin_auth = _register(client, "admin_ban")
|
||||
|
||||
msg_id = _send_dm(client, bad_user, victim, "terrible violation")
|
||||
rep_resp = client.post(
|
||||
f"/api/chat/messages/{msg_id}/report",
|
||||
headers=victim["headers"],
|
||||
json={"reason": "malicious user"},
|
||||
)
|
||||
report_id = rep_resp.json()["id"]
|
||||
|
||||
resolve_resp = client.post(
|
||||
f"/api/moderation/reports/{report_id}/resolve",
|
||||
headers=admin_auth["headers"],
|
||||
json={"action": "ban", "notes": "Banned malicious user"},
|
||||
)
|
||||
assert resolve_resp.status_code == 200
|
||||
|
||||
# Attempting to use bad_user's token fails with 403 inactive account
|
||||
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
|
||||
assert bad_me.status_code == 403
|
||||
Reference in New Issue
Block a user