"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters.""" import asyncio from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.main import app from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.sources import ( SOURCE_REGISTRY, GrafanaWidgetSource, SshTaskWidgetSource, StaticWidgetSource, ) @pytest.fixture def client(tmp_path): """FastAPI test client with a fresh settings store and auth disabled.""" store = SettingsStore(tmp_path / "settings.sqlite") store.ensure_defaults() app.dependency_overrides[get_settings_store] = lambda: store auth_settings = SimpleNamespace(auth_enabled=False) with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings): yield TestClient(app) app.dependency_overrides.clear() def test_widget_sources(client): response = client.get("/api/widgets/sources") assert response.status_code == 200 assert set(response.json()) == { "jellyfin", "backups", "grafana", "prometheus", "ssh_task", "static", } def test_widget_types(client): response = client.get("/api/widgets/types") assert response.status_code == 200 types = {item["widget_type"] for item in response.json()} assert types == { "jellyfin", "backups", "grafana-link", "prometheus-metric", "ssh-task", "static", } def test_create_and_read_widget(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Note", "config": {"text": "hello"}, "enabled": True, "sort_order": 5, }, ) assert response.status_code == 201 widget = response.json() assert widget["title"] == "Note" assert widget["config"] == {"text": "hello"} assert widget["enabled"] is True assert widget["sort_order"] == 5 widget_id = widget["id"] response = client.get("/api/widgets/instances") assert response.status_code == 200 assert any(w["id"] == widget_id for w in response.json()) def test_update_widget(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Note", "config": {"text": "hello"}, }, ) widget_id = response.json()["id"] response = client.put( f"/api/widgets/instances/{widget_id}", json={ "addon_id": "core", "widget_type": "static", "title": "Updated", "config": {"text": "world"}, "enabled": False, "sort_order": 10, }, ) assert response.status_code == 200 data = response.json() assert data["title"] == "Updated" assert data["config"] == {"text": "world"} assert data["enabled"] is False assert data["sort_order"] == 10 def test_delete_widget(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "To delete", "config": {"text": "bye"}, }, ) widget_id = response.json()["id"] response = client.delete(f"/api/widgets/instances/{widget_id}") assert response.status_code == 200 response = client.get("/api/widgets/instances") assert not any(w["id"] == widget_id for w in response.json()) def test_unknown_widget_type_rejected(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "unknown", "title": "Bad", "config": {}, }, ) assert response.status_code == 422 def test_addon_id_mismatch_rejected(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "grafana", "widget_type": "static", "title": "Bad", "config": {"text": "x"}, }, ) assert response.status_code == 422 def test_credential_key_rejected(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Bad", "config": {"api_key": "secret123"}, }, ) assert response.status_code == 422 def test_update_nonexistent_widget(client): response = client.put( "/api/widgets/instances/does-not-exist", json={ "addon_id": "core", "widget_type": "static", "title": "Bad", "config": {"text": "x"}, }, ) assert response.status_code == 404 def test_delete_nonexistent_widget(client): response = client.delete("/api/widgets/instances/does-not-exist") assert response.status_code == 404 def test_default_widgets_seeded(client): response = client.get("/api/widgets/instances") assert response.status_code == 200 widgets = response.json() types = [w["widget_type"] for w in widgets] assert "jellyfin" in types assert "backups" in types def test_no_reseed_when_widgets_exist(tmp_path): db_path = tmp_path / "settings.sqlite" store = SettingsStore(db_path) store.ensure_defaults() widgets = store.list_widgets() assert len(widgets) == 2 store.delete_widget(widgets[0]["id"]) store.ensure_defaults() remaining = store.list_widgets() assert len(remaining) == 1 def test_update_id_mismatch_returns_400(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Note", "config": {"text": "hello"}, }, ) widget_id = response.json()["id"] response = client.put( f"/api/widgets/instances/{widget_id}", json={ "id": "different-id", "addon_id": "core", "widget_type": "static", "title": "Updated", "config": {"text": "world"}, }, ) assert response.status_code == 400 def test_empty_title_rejected(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "", "config": {"text": "hello"}, }, ) assert response.status_code == 422 def test_config_type_error_rejected(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "grafana", "widget_type": "grafana-link", "title": "Grafana", "config": {"panel_id": "not-an-integer"}, }, ) assert response.status_code == 422 def test_list_instances_respects_sort_order(client): response = client.get("/api/widgets/instances") assert response.status_code == 200 widgets = response.json() orders = [w["sort_order"] for w in widgets] assert orders == sorted(orders) def test_enabled_round_trip(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Toggle", "config": {"text": "x"}, "enabled": False, }, ) widget_id = response.json()["id"] response = client.put( f"/api/widgets/instances/{widget_id}", json={ "addon_id": "core", "widget_type": "static", "title": "Toggle", "config": {"text": "x"}, "enabled": True, }, ) assert response.status_code == 200 assert response.json()["enabled"] is True def test_fetch_static_widget_data(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Note", "config": {"text": "hello world"}, }, ) widget_id = response.json()["id"] response = client.get(f"/api/widgets/instances/{widget_id}/data") assert response.status_code == 200 data = response.json() assert data["widget_id"] == widget_id assert data["widget_type"] == "static" assert data["data"] == {"text": "hello world"} assert data["error"] is None assert isinstance(data["fetched_at"], int) def test_fetch_grafana_widget_data(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "grafana", "widget_type": "grafana-link", "title": "Grafana", "config": {"dashboard_uid": "overview", "panel_id": 3}, }, ) widget_id = response.json()["id"] response = client.get(f"/api/widgets/instances/{widget_id}/data") assert response.status_code == 200 data = response.json() assert data["widget_type"] == "grafana-link" assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3" def test_fetch_prometheus_widget_data(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "prometheus", "widget_type": "prometheus-metric", "title": "CPU", "config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'}, }, ) widget_id = response.json()["id"] fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}} with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get: mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = fake_payload mock_get.return_value = mock_response response = client.get(f"/api/widgets/instances/{widget_id}/data") assert response.status_code == 200 data = response.json() assert data["widget_type"] == "prometheus-metric" assert data["data"]["result"]["resultType"] == "scalar" def test_fetch_jellyfin_widget_data_error(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "jellyfin", "title": "Activity", "config": {"machine_id": ""}, }, ) widget_id = response.json()["id"] response = client.get(f"/api/widgets/instances/{widget_id}/data") assert response.status_code == 200 data = response.json() assert data["widget_type"] == "jellyfin" assert data["data"] is None assert data["error"] is not None assert "Jellyfin" in data["error"] or "machine" in data["error"].lower() def test_fetch_widget_data_not_found(client): response = client.get("/api/widgets/instances/does-not-exist/data") assert response.status_code == 404 def test_fetch_widget_data_unhandled_exception_returns_500(client): response = client.post( "/api/widgets/instances", json={ "addon_id": "core", "widget_type": "static", "title": "Note", "config": {"text": "x"}, }, ) widget_id = response.json()["id"] class _ExplodingAdapter: source_type = "static" async def fetch(self, config): raise RuntimeError("boom") with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()): response = client.get(f"/api/widgets/instances/{widget_id}/data") assert response.status_code == 500 @pytest.mark.asyncio async def test_static_adapter(): adapter = StaticWidgetSource() result = await adapter.fetch({"text": "hello"}) assert result == {"text": "hello"} @pytest.mark.asyncio async def test_grafana_adapter(): adapter = GrafanaWidgetSource() result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2}) assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2" result = await adapter.fetch({"dashboard_uid": "overview"}) assert result["url"] == "http://grafana:3000/d/overview" @pytest.mark.asyncio async def test_ssh_task_adapter_timeout(tmp_path): store = SettingsStore(tmp_path / "settings.sqlite") store.ensure_defaults() # Create a local machine and a simple shell task. machine = store.list_machines()[0] task = store.upsert_task( { "name": "slow-task", "task_type": "shell", "content": "echo hello", "enabled": True, "default_machine_id": machine["id"], } ) adapter = SshTaskWidgetSource() with patch( "media_library_viewer_api.widgets.sources.get_settings_store", return_value=store, ), patch( "media_library_viewer_api.widgets.sources.asyncio.wait_for", side_effect=asyncio.TimeoutError, ): result = await adapter.fetch({"task_id": task["id"]}) assert "error" in result assert "timed out" in result["error"].lower() def test_source_registry_closed(): assert set(SOURCE_REGISTRY.keys()) == { "jellyfin", "backups", "grafana", "prometheus", "ssh_task", "static", }