""" P-04: Copyright-Pflicht bei Promotion auf club/official. PATCH /api/media-assets/{id} und POST /api/media-assets/bulk-patch lehnen eine Sichtbarkeits-Promotion auf club oder official ab, wenn keine copyright_notice vorhanden ist. """ from __future__ import annotations import os from contextlib import ExitStack from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient os.environ.setdefault("SKIP_DB_MIGRATE", "1") from main import app from tenant_context import TenantContext, get_tenant_context _SUPERADMIN_TENANT = TenantContext( profile_id=1, global_role="superadmin", effective_club_id=None, club_ids=frozenset(), memberships=[], ) _PRIVATE_ASSET: dict = { "id": 42, "visibility": "private", "club_id": 7, "uploaded_by_profile_id": 1, "lifecycle_state": "active", "copyright_notice": None, "original_filename": "foto.jpg", "sha256": "a" * 64, "storage_key": f"library/verein-7/image/{'a' * 64}.jpg", "storage_backend": "local", "mime_type": "image/jpeg", "byte_size": 1024, "created_at": None, "tags": [], } _ASSET_WITH_COPYRIGHT: dict = {**_PRIVATE_ASSET, "copyright_notice": "Verein 2026"} @pytest.fixture def client() -> TestClient: return TestClient(app) @pytest.fixture(autouse=True) def _clear_overrides(): yield app.dependency_overrides.pop(get_tenant_context, None) def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]: mock_cur = MagicMock() mock_cur.fetchone.return_value = asset mock_conn = MagicMock() mock_cm = MagicMock() mock_cm.__enter__.return_value = mock_conn mock_cm.__exit__.return_value = False return mock_cm, mock_cur _PERMISSION_PATCHES = [ ("routers.media_assets.assert_can_edit_media_asset_metadata", {}), ("routers.media_assets.assert_valid_governance_visibility", {}), ("routers.media_assets._media_assets_tags_column_present", {"return_value": False}), ("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}), ("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}), ] def _enter_permission_patches(stack: ExitStack) -> None: for target, kwargs in _PERMISSION_PATCHES: stack.enter_context(patch(target, **kwargs)) # ── Single PATCH ───────────────────────────────────────────────────────────── def test_patch_promote_to_club_without_copyright_returns_400(client: TestClient) -> None: """Promotion private -> club ohne copyright_notice muss 400 liefern.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.patch( "/api/media-assets/42", json={"visibility": "club", "club_id": 7}, headers={"X-Auth-Token": "t"}, ) assert r.status_code == 400 detail = r.json()["detail"].lower() assert "copyright" in detail or "urheberrecht" in detail def test_patch_promote_to_official_without_copyright_returns_400(client: TestClient) -> None: """Promotion private -> official ohne copyright_notice muss 400 liefern.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.patch( "/api/media-assets/42", json={"visibility": "official"}, headers={"X-Auth-Token": "t"}, ) assert r.status_code == 400 detail = r.json()["detail"].lower() assert "copyright" in detail or "urheberrecht" in detail def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None: """Promotion private -> club MIT copyright_notice im Body darf nicht 400 liefern.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) mock_cur.fetchone.side_effect = [ _PRIVATE_ASSET, {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"}, ] with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.patch( "/api/media-assets/42", json={"visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026"}, headers={"X-Auth-Token": "t"}, ) assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower() def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None: """Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright erlaubt.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT) mock_cur.fetchone.side_effect = [ _ASSET_WITH_COPYRIGHT, {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}, ] with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.patch( "/api/media-assets/42", json={"visibility": "club", "club_id": 7}, headers={"X-Auth-Token": "t"}, ) assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower() def test_patch_filename_only_no_copyright_check(client: TestClient) -> None: """Kein Visibility-Wechsel -> keine Copyright-Prufung.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) mock_cur.fetchone.side_effect = [ _PRIVATE_ASSET, {**_PRIVATE_ASSET, "original_filename": "neu.jpg"}, ] with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.patch( "/api/media-assets/42", json={"original_filename": "neu.jpg"}, headers={"X-Auth-Token": "t"}, ) assert r.status_code == 200 # ── Bulk PATCH ──────────────────────────────────────────────────────────────── def test_bulk_patch_promote_to_club_without_copyright_in_failed(client: TestClient) -> None: """Bulk-Promotion ohne copyright_notice -> Asset in failed-Liste.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cur = MagicMock() mock_cur.fetchone.return_value = _PRIVATE_ASSET mock_conn = MagicMock() mock_cm = MagicMock() mock_cm.__enter__.return_value = mock_conn mock_cm.__exit__.return_value = False with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.post( "/api/media-assets/bulk-patch", json={"media_asset_ids": [42], "visibility": "club", "club_id": 7}, headers={"X-Auth-Token": "t"}, ) assert r.status_code == 200 body = r.json() assert body["updated_count"] == 0 assert body["failed_count"] == 1 detail = body["failed"][0]["detail"].lower() assert "copyright" in detail or "urheberrecht" in detail def test_bulk_patch_promote_to_club_with_copyright_in_updated(client: TestClient) -> None: """Bulk-Promotion MIT copyright_notice -> Asset in updated-Liste.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT mock_cur = MagicMock() mock_cur.fetchone.return_value = _PRIVATE_ASSET mock_conn = MagicMock() mock_cm = MagicMock() mock_cm.__enter__.return_value = mock_conn mock_cm.__exit__.return_value = False with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) _enter_permission_patches(stack) r = client.post( "/api/media-assets/bulk-patch", json={ "media_asset_ids": [42], "visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026", }, headers={"X-Auth-Token": "t"}, ) assert r.status_code == 200 body = r.json() assert 42 in body["updated"] assert body["updated_count"] == 1