""" 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 @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_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}, {"c": 0}, { "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": "exercises/x.jpg", }, {"id": 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.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": "/media/exercises/h.jpg", "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}, {"c": 0}, { "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": "exercises/h.jpg", }, None, inserted, ] 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.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": None, "uploaded_by_profile_id": 1, "lifecycle_state": "trash_soft", "storage_key": "exercises/a.mp4", "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"