shinkan-jinkendo/backend/tests/test_media_assets_archive.py
Lars 34235ef46d
Some checks failed
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Failing after 1m23s
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).

Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
  check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
  write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
  media_asset_rights_declarations (Append-only Audit-Log), Felder
  rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
  POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
  GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
  assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert

Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
  (konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
  Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet

Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt

Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log

Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.

version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00

509 lines
18 KiB
Python

"""
Medienarchiv: GET /api/media-assets und POST /api/exercises/{id}/media/from-asset (gemockte DB).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
_SK_OFF_A = f"library/official/image/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/verein-c1/video/{'c' * 64}.u1.mp4"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides() -> None:
yield
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(get_tenant_context, None)
def _mock_db(mock_cur: MagicMock) -> MagicMock:
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm
def test_list_media_assets_ok_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.side_effect = [
[
{
"id": 1,
"mime_type": "image/png",
"byte_size": 100,
"original_filename": "a.png",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 2,
"lifecycle_state": "active",
"created_at": None,
"sha256": "a" * 64,
"copyright_notice": None,
"storage_key": "media/a.png",
"tags": ["demo"],
"uploader_name": None,
"uploader_email": None,
"club_name": None,
}
],
[],
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["limit"] == 30
assert len(body["items"]) == 1
assert body["items"][0]["original_filename"] == "a.png"
assert body["items"][0]["usage"] == {"exercises": [], "training_units": []}
assert body["items"][0]["tags"] == ["demo"]
assert "viewer" in body
def test_list_media_assets_media_kind_image_ok_mocked(client: TestClient) -> None:
"""media_kind=image setzt %% in SQL — Regression gegen psycopg2-%-Platzhalter."""
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.side_effect = [[], []]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?media_kind=image", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
calls = [str(c) for c in mock_cur.execute.call_args_list]
joined = " ".join(calls)
assert "image/%%" in joined
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None}, # _assert_can_edit_exercise
{"c": 0}, # _count_exercise_media
{
"id": 5,
"mime_type": "image/jpeg",
"byte_size": 10,
"original_filename": "x.jpg",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": _SK_OFF_A,
}, # asset lookup
{"visibility": "private"}, # P-06: exercise visibility
{"id": 1}, # duplicate check -> 400
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
), patch("routers.exercises.assert_rights_for_exercise_link"):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"media_asset_id": 5,
"title": "",
"description": "",
"context": "ablauf",
"is_primary": False,
},
)
assert r.status_code == 400
assert "bereits" in (r.json().get("detail") or "").lower()
def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
inserted = {
"id": 99,
"exercise_id": 3,
"media_type": "image",
"file_path": f"/media/{_SK_OFF_B}",
"file_size": 10,
"mime_type": "image/jpeg",
"original_filename": "h.jpg",
"embed_url": None,
"embed_platform": None,
"title": "h.jpg",
"description": None,
"sort_order": 1,
"is_primary": False,
"context": "ablauf",
"created_at": None,
"media_asset_id": 5,
}
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None}, # _assert_can_edit_exercise
{"c": 0}, # _count_exercise_media
{
"id": 5,
"mime_type": "image/jpeg",
"byte_size": 10,
"original_filename": "h.jpg",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": _SK_OFF_B,
}, # asset lookup
{"visibility": "private"}, # P-06: exercise visibility
None, # duplicate check -> None (no duplicate)
inserted, # INSERT RETURNING
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
), patch("routers.exercises.assert_rights_for_exercise_link"):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"media_asset_id": 5,
"context": "detail",
"is_primary": False,
},
)
assert r.status_code == 201
body = r.json()
assert body["id"] == 99
assert body["media_asset_id"] == 5
assert body["asset_lifecycle_state"] == "active"
def test_delete_exercise_media_returns_orphan_when_last_ref(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": 88},
{"c": 1},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["orphan_media_asset_id"] == 88
def test_delete_exercise_media_no_orphan_when_shared(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": 88},
{"c": 2},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["orphan_media_asset_id"] is None
def test_delete_exercise_media_embed_row_no_orphan(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": None},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/21", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json() == {"ok": True, "orphan_media_asset_id": None}
def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{
"id": 5,
"visibility": "private",
"club_id": 1,
"uploaded_by_profile_id": 1,
"lifecycle_state": "trash_soft",
"storage_key": _SK_PRIV_C,
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,
"purge_after_at": None,
},
{"id": 5, "lifecycle_state": "active"},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/media-assets/5/lifecycle",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"action": "reactivate"},
)
assert r.status_code == 200
assert r.json()["lifecycle_state"] == "active"
def test_media_asset_lifecycle_purge_forbidden_for_non_superadmin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "admin"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="admin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cm = _mock_db(mock_cur)
fake_row = {
"id": 99,
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 2,
"lifecycle_state": "trash_hidden",
"storage_key": "x",
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,
"purge_after_at": None,
}
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.fetch_media_asset_row", return_value=fake_row):
r = client.post(
"/api/media-assets/99/lifecycle",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"action": "purge"},
)
assert r.status_code == 403
assert "Superadmin" in (r.json().get("detail") or "")
def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.return_value = []
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["lifecycle"] == "trash_soft"
list_sql_calls = [c[0][0] for c in mock_cur.execute.call_args_list if c[0] and "FROM media_assets ma" in str(c[0][0])]
assert list_sql_calls and "trash_soft" in list_sql_calls[0]
def test_list_media_assets_invalid_media_kind_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?media_kind=movies", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_list_media_assets_club_filter_forbidden_non_superadmin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "admin"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="admin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?club_id=3", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?lifecycle=invalid", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{
"id": 9,
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.png",
},
{
"id": 9,
"mime_type": "image/png",
"byte_size": 10,
"original_filename": "x.png",
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"created_at": None,
"sha256": "b" * 64,
"copyright_notice": "© HoldCo",
"tags": [],
},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("media_lifecycle.assert_can_manage_media_asset_lifecycle", lambda *a, **k: None):
r = client.patch(
"/api/media-assets/9",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"copyright_notice": "© HoldCo"},
)
assert r.status_code == 200
assert r.json()["copyright_notice"] == "© HoldCo"