All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 29s
- Added a new API endpoint for bulk uploading media assets, allowing users to upload multiple files in a single request. - Implemented validation for file types and sizes during the upload process, ensuring compliance with allowed formats and limits. - Enhanced the MediaLibraryPage component to support bulk file selection and visibility options, improving user experience. - Updated CSS styles for the upload interface to enhance layout and accessibility. - Added tests to verify the functionality of the new bulk upload feature and its integration with existing media asset management.
502 lines
17 KiB
Python
502 lines
17 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
|
|
|
|
|
|
@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},
|
|
{"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"
|
|
|