All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 58s
- Added new API endpoints for content reporting, including submission, retrieval, and status updates. - Created database migration for `content_reports` table to store report data. - Integrated content reports into the existing admin inbox for better management. - Implemented validation for report submissions, including required fields and email format. - Added tests for content reporting functionality, covering various scenarios and edge cases. - Updated frontend API utility to include new content report methods. - Bumped app version to 0.8.87 and updated relevant page versions.
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""
|
||
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"
|
||
with pytest.raises(HTTPException) as exc:
|
||
list_inbox_content_reports(tenant=tenant)
|
||
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()
|
||
existing_row.__getitem__ = lambda s, k: {"id": 5, "status": "submitted"}[k]
|
||
existing_row.keys = lambda: ["id", "status"]
|
||
|
||
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"
|