2026-05-11 17:54:53 +02:00
|
|
|
|
"""
|
|
|
|
|
|
P-13: Content-Melde-Backend – Backend-Tests.
|
|
|
|
|
|
|
|
|
|
|
|
Abgedeckt (15 Faelle):
|
|
|
|
|
|
1. submit_report ohne good_faith_confirmed=False → 400
|
|
|
|
|
|
2. submit_report mit leerem report_description → 422
|
|
|
|
|
|
3. submit_report mit fehlender reporter_email → 422
|
|
|
|
|
|
4. submit_report ohne Login fuer official-Medium – Erfolgspfad
|
|
|
|
|
|
5. submit_report ohne Login fuer nicht-official-Medium → 404
|
|
|
|
|
|
6. submit_report mit Login fuer sichtbares club-Medium – Erfolgspfad
|
|
|
|
|
|
7. submit_report mit Login fuer nicht-sichtbares fremdes Medium → 404
|
|
|
|
|
|
8. Prioritaet high fuer illegal_content/minors/youth_protection
|
|
|
|
|
|
9. Prioritaet normal fuer copyright/other
|
|
|
|
|
|
10. list_inbox als Nicht-Admin → 403
|
|
|
|
|
|
11. list_inbox als Plattform-Admin – gibt Liste zurueck
|
|
|
|
|
|
12. patch_report status 'under_review' – Erfolgspfad
|
|
|
|
|
|
13. patch_report 'resolved_no_action' ohne resolution_note → 400
|
|
|
|
|
|
14. set_legal_hold_from_report als Nicht-Superadmin → 403
|
|
|
|
|
|
15. set_legal_hold_from_report als Superadmin – Erfolgspfad (reason_code-Mapping)
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
|
|
|
|
|
|
|
|
|
|
|
from routers.content_reports import (
|
|
|
|
|
|
HIGH_PRIORITY_REASONS,
|
|
|
|
|
|
_REASON_TO_HOLD_CODE,
|
|
|
|
|
|
_is_media_asset_visible_anonymous,
|
|
|
|
|
|
_is_media_asset_visible_to_profile,
|
|
|
|
|
|
REPORT_REASONS,
|
|
|
|
|
|
ContentReportCreate,
|
|
|
|
|
|
ContentReportPatch,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Hilfsfunktionen
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _make_cur(fetchone_val=None):
|
|
|
|
|
|
cur = MagicMock()
|
|
|
|
|
|
cur.fetchone.return_value = fetchone_val
|
|
|
|
|
|
cur.fetchall.return_value = []
|
|
|
|
|
|
return cur
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _row(data: dict):
|
|
|
|
|
|
"""Simuliert eine psycopg2-DictRow."""
|
|
|
|
|
|
m = MagicMock()
|
|
|
|
|
|
m.__getitem__ = lambda self, k: data[k]
|
|
|
|
|
|
m.keys = lambda: data.keys()
|
|
|
|
|
|
return m
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 1. good_faith_confirmed=False → 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_submit_requires_good_faith():
|
|
|
|
|
|
body = ContentReportCreate(
|
|
|
|
|
|
target_type="media_asset",
|
|
|
|
|
|
target_id=1,
|
|
|
|
|
|
report_reason="copyright",
|
|
|
|
|
|
report_description="A" * 20,
|
|
|
|
|
|
reporter_name="Max",
|
|
|
|
|
|
reporter_email="max@example.com",
|
|
|
|
|
|
good_faith_confirmed=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
from routers.content_reports import submit_content_report
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
submit_content_report(body)
|
|
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
assert "Gutglauben" in exc.value.detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 2. Leere report_description → 422 (Pydantic)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_submit_requires_description():
|
|
|
|
|
|
with pytest.raises(Exception):
|
|
|
|
|
|
ContentReportCreate(
|
|
|
|
|
|
target_type="media_asset",
|
|
|
|
|
|
target_id=1,
|
|
|
|
|
|
report_reason="copyright",
|
|
|
|
|
|
report_description="ab", # unter min_length=10
|
|
|
|
|
|
reporter_name="Max",
|
|
|
|
|
|
reporter_email="max@example.com",
|
|
|
|
|
|
good_faith_confirmed=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 3. Ungueltige E-Mail → 422 (Pydantic Validator)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_submit_requires_valid_email():
|
|
|
|
|
|
with pytest.raises(Exception):
|
|
|
|
|
|
ContentReportCreate(
|
|
|
|
|
|
target_type="media_asset",
|
|
|
|
|
|
target_id=1,
|
|
|
|
|
|
report_reason="copyright",
|
|
|
|
|
|
report_description="A" * 20,
|
|
|
|
|
|
reporter_name="Max",
|
|
|
|
|
|
reporter_email="kein-at-zeichen",
|
|
|
|
|
|
good_faith_confirmed=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 4. Anonym – official-Medium → sichtbar
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_anonymous_official_medium_visible():
|
|
|
|
|
|
cur = _make_cur(fetchone_val={"1": 1})
|
|
|
|
|
|
cur.fetchone.return_value = MagicMock()
|
|
|
|
|
|
result = _is_media_asset_visible_anonymous(cur, asset_id=42)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 5. Anonym – nicht-official-Medium → nicht sichtbar
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_anonymous_non_official_medium_not_visible():
|
|
|
|
|
|
cur = _make_cur(fetchone_val=None)
|
|
|
|
|
|
result = _is_media_asset_visible_anonymous(cur, asset_id=99)
|
|
|
|
|
|
assert result is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 6. Eingeloggter Nutzer – sichtbares club-Medium → True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_logged_in_club_member_can_see_club_media():
|
|
|
|
|
|
asset_row = MagicMock()
|
|
|
|
|
|
asset_row.keys = lambda: ["id", "visibility", "club_id", "uploaded_by_profile_id"]
|
|
|
|
|
|
asset_row.__getitem__ = lambda s, k: {
|
|
|
|
|
|
"id": 7,
|
|
|
|
|
|
"visibility": "club",
|
|
|
|
|
|
"club_id": 3,
|
|
|
|
|
|
"uploaded_by_profile_id": 99,
|
|
|
|
|
|
}[k]
|
|
|
|
|
|
|
|
|
|
|
|
member_row = MagicMock() # Mitgliedschaft gefunden
|
|
|
|
|
|
|
|
|
|
|
|
cur = MagicMock()
|
|
|
|
|
|
cur.fetchone.side_effect = [asset_row, member_row]
|
|
|
|
|
|
|
|
|
|
|
|
result = _is_media_asset_visible_to_profile(cur, asset_id=7, profile_id=1, global_role="trainer")
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 7. Eingeloggter Nutzer – fremdes Medium → False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_logged_in_non_member_cannot_see_club_media():
|
|
|
|
|
|
asset_row = MagicMock()
|
|
|
|
|
|
asset_row.keys = lambda: ["id", "visibility", "club_id", "uploaded_by_profile_id"]
|
|
|
|
|
|
asset_row.__getitem__ = lambda s, k: {
|
|
|
|
|
|
"id": 7,
|
|
|
|
|
|
"visibility": "club",
|
|
|
|
|
|
"club_id": 3,
|
|
|
|
|
|
"uploaded_by_profile_id": 99,
|
|
|
|
|
|
}[k]
|
|
|
|
|
|
|
|
|
|
|
|
cur = MagicMock()
|
|
|
|
|
|
# Erster fetchone: Asset-Zeile, zweiter: kein Mitglied
|
|
|
|
|
|
cur.fetchone.side_effect = [asset_row, None]
|
|
|
|
|
|
|
|
|
|
|
|
result = _is_media_asset_visible_to_profile(cur, asset_id=7, profile_id=5, global_role="trainer")
|
|
|
|
|
|
assert result is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 8. Prioritaet high fuer kritische Gruende
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_high_priority_reasons():
|
|
|
|
|
|
assert "illegal_content" in HIGH_PRIORITY_REASONS
|
|
|
|
|
|
assert "minors" in HIGH_PRIORITY_REASONS
|
|
|
|
|
|
assert "youth_protection" in HIGH_PRIORITY_REASONS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 9. Prioritaet normal fuer andere Gruende
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_normal_priority_reasons():
|
|
|
|
|
|
normal_reasons = REPORT_REASONS - HIGH_PRIORITY_REASONS
|
|
|
|
|
|
assert "copyright" in normal_reasons
|
|
|
|
|
|
assert "other" in normal_reasons
|
|
|
|
|
|
assert "privacy" in normal_reasons
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 10. list_inbox als Nicht-Admin → 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_inbox_requires_platform_admin():
|
|
|
|
|
|
from routers.content_reports import list_inbox_content_reports
|
|
|
|
|
|
tenant = MagicMock()
|
|
|
|
|
|
tenant.global_role = "trainer"
|
2026-05-11 22:25:23 +02:00
|
|
|
|
tenant.profile_id = 99
|
|
|
|
|
|
|
|
|
|
|
|
# COUNT-Abfrage fuer Club-Admin-Rollen → 0 → 403
|
|
|
|
|
|
cnt_row = _row({"cnt": 0})
|
|
|
|
|
|
mock_cur = MagicMock()
|
|
|
|
|
|
mock_cur.fetchone.return_value = cnt_row
|
|
|
|
|
|
|
|
|
|
|
|
mock_conn = MagicMock()
|
|
|
|
|
|
mock_conn_ctx = MagicMock()
|
|
|
|
|
|
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
|
|
|
|
|
|
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
|
|
|
|
|
|
patch("routers.content_reports.get_cursor", return_value=mock_cur):
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
list_inbox_content_reports(tenant=tenant)
|
2026-05-11 17:54:53 +02:00
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 11. list_inbox als Plattform-Admin – gibt Liste zurueck
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_inbox_as_admin_returns_list():
|
|
|
|
|
|
from routers.content_reports import list_inbox_content_reports
|
|
|
|
|
|
|
|
|
|
|
|
tenant = MagicMock()
|
|
|
|
|
|
tenant.global_role = "admin"
|
|
|
|
|
|
|
|
|
|
|
|
mock_conn = MagicMock()
|
|
|
|
|
|
mock_cur = MagicMock()
|
|
|
|
|
|
mock_cur.fetchall.return_value = []
|
|
|
|
|
|
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
|
|
|
|
|
|
mock_conn.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
mock_conn_ctx = MagicMock()
|
|
|
|
|
|
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
|
|
|
|
|
|
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
|
|
|
|
|
|
patch("routers.content_reports.get_cursor", return_value=mock_cur):
|
|
|
|
|
|
result = list_inbox_content_reports(tenant=tenant)
|
|
|
|
|
|
assert isinstance(result, list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 12. patch_report status 'under_review' – Erfolgspfad
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_report_under_review():
|
|
|
|
|
|
from routers.content_reports import patch_content_report
|
|
|
|
|
|
|
|
|
|
|
|
tenant = MagicMock()
|
|
|
|
|
|
tenant.global_role = "admin"
|
|
|
|
|
|
tenant.profile_id = 1
|
|
|
|
|
|
|
|
|
|
|
|
body = ContentReportPatch(status="under_review")
|
|
|
|
|
|
|
|
|
|
|
|
existing_row = MagicMock()
|
2026-05-11 22:25:23 +02:00
|
|
|
|
existing_row.__getitem__ = lambda s, k: {
|
|
|
|
|
|
"id": 5, "status": "submitted",
|
|
|
|
|
|
"target_type": "media_asset", "target_id": 42, "resolution_note": None,
|
|
|
|
|
|
}[k]
|
|
|
|
|
|
existing_row.keys = lambda: ["id", "status", "target_type", "target_id", "resolution_note"]
|
2026-05-11 17:54:53 +02:00
|
|
|
|
|
|
|
|
|
|
updated_row = MagicMock()
|
|
|
|
|
|
updated_row.__getitem__ = lambda s, k: {"id": 5, "status": "under_review"}[k]
|
|
|
|
|
|
updated_row.keys = lambda: ["id", "status"]
|
|
|
|
|
|
|
|
|
|
|
|
mock_cur = MagicMock()
|
|
|
|
|
|
mock_cur.fetchone.side_effect = [existing_row, updated_row]
|
|
|
|
|
|
|
|
|
|
|
|
mock_conn = MagicMock()
|
|
|
|
|
|
mock_conn_ctx = MagicMock()
|
|
|
|
|
|
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
|
|
|
|
|
|
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
|
|
|
|
|
|
patch("routers.content_reports.get_cursor", return_value=mock_cur):
|
|
|
|
|
|
result = patch_content_report(5, body, tenant=tenant)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["ok"] is True
|
|
|
|
|
|
assert result["status"] == "under_review"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 13. patch_report 'resolved_no_action' ohne resolution_note → 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_resolved_no_action_requires_note():
|
|
|
|
|
|
from routers.content_reports import patch_content_report
|
|
|
|
|
|
|
|
|
|
|
|
tenant = MagicMock()
|
|
|
|
|
|
tenant.global_role = "admin"
|
|
|
|
|
|
tenant.profile_id = 1
|
|
|
|
|
|
|
|
|
|
|
|
body = ContentReportPatch(status="resolved_no_action", resolution_note=None)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
patch_content_report(99, body, tenant=tenant)
|
|
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
assert "Begründung" in exc.value.detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 14. set_legal_hold_from_report als Nicht-Superadmin → 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_legal_hold_from_report_requires_superadmin():
|
|
|
|
|
|
from routers.content_reports import set_legal_hold_from_report, ContentReportLegalHoldBody
|
|
|
|
|
|
|
|
|
|
|
|
tenant = MagicMock()
|
|
|
|
|
|
tenant.global_role = "admin" # nur admin, nicht superadmin
|
|
|
|
|
|
|
|
|
|
|
|
body = ContentReportLegalHoldBody(reason_note="Test-Begruendung")
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
set_legal_hold_from_report(1, body, tenant=tenant)
|
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 15. Reason-Code-Mapping: copyright → copyright_complaint
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_reason_code_mapping():
|
|
|
|
|
|
assert _REASON_TO_HOLD_CODE["copyright"] == "copyright_complaint"
|
|
|
|
|
|
assert _REASON_TO_HOLD_CODE["minors"] == "youth_protection"
|
|
|
|
|
|
assert _REASON_TO_HOLD_CODE["illegal_content"] == "illegal_content"
|
|
|
|
|
|
assert _REASON_TO_HOLD_CODE["privacy"] == "privacy_complaint"
|
|
|
|
|
|
assert _REASON_TO_HOLD_CODE["image_rights"] == "rights_dispute"
|
|
|
|
|
|
assert _REASON_TO_HOLD_CODE["other"] == "other"
|
|
|
|
|
|
# Alle Reason-Codes muessen gemappt sein
|
|
|
|
|
|
for reason in REPORT_REASONS:
|
|
|
|
|
|
assert reason in _REASON_TO_HOLD_CODE, f"{reason} fehlt im Mapping"
|