Files
manage/backend/tests/test_widgets.py
T
Developer b877a32ad8 Add Jellyfin Now Playing + Grafana Panel embed widgets
Two new additive widget kinds:

Jellyfin 'now_playing': like the existing 'activity' widget but filters
to only sessions with active playback (NowPlayingItem present + not
paused). Shows who's actually watching right now. The 'activity' kind
is unchanged (shows all sessions including idle).

Grafana 'panel': embeds a single Grafana panel directly in the app via
an iframe, using Grafana's /d-solo/ endpoint (renders one panel without
dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and
time range (from/to, defaults now-1h/now). Includes a fallback 'Open in
Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The
'link' kind is unchanged (still builds a deep-link URL).

Backend: new widget configs + definitions on jellyfin/grafana; source
adapter logic (session filter for now_playing; d-solo embed URL for
panel); 6 new tests.

Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components;
registry bindings; 6 new tests.

278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build
green both sides.
2026-06-28 15:26:43 +00:00

602 lines
21 KiB
Python

"""Tests for the dashboard widget system: service-bound + built-in widgets."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
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 (
AlertmanagerWidgetSource,
BackupsWidgetSource,
GrafanaWidgetSource,
JellyfinWidgetSource,
ServiceRecord,
StaticWidgetSource,
)
TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
yield
@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 _make_grafana_service(client, name="Production Grafana", **config_overrides):
config = {"base_url": "https://grafana.example.com"}
config.update(config_overrides)
return client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
).json()
# ---------------------------------------------------------------------------
# Built-in kinds + built-in widget CRUD
# ---------------------------------------------------------------------------
def test_list_builtin_kinds(client):
response = client.get("/api/widgets/builtin")
assert response.status_code == 200
kinds = {item["kind"] for item in response.json()}
assert kinds == {"backups", "static"}
def test_create_and_read_static_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"widget_kind": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
assert response.status_code == 201
created = response.json()
assert created["widget_kind"] == "static"
assert created["service_id"] is None
assert created["config"]["text"] == "hello"
listed = client.get("/api/widgets/instances").json()
assert len(listed) == 1
assert listed[0]["id"] == created["id"]
def test_create_backups_widget(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
)
assert response.status_code == 201
def test_widget_filtering_by_service_id_and_scope(client):
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
service = _make_grafana_service(client)
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
)
client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dash",
"config": {"dashboard_uid": "o"},
},
)
# No filter: both widgets.
all_widgets = client.get("/api/widgets/instances").json()
assert len(all_widgets) == 2
# Filter by service_id: only the service-scoped one.
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
assert len(by_service) == 1
assert by_service[0]["service_id"] == service["id"]
# scope=dashboard: only the built-in (NULL service_id).
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
assert len(dashboard_scope) == 1
assert dashboard_scope[0]["service_id"] is None
# scope=service: only the non-null service_id widget.
service_scope = client.get("/api/widgets/instances?scope=service").json()
assert len(service_scope) == 1
assert service_scope[0]["service_id"] == service["id"]
def test_unknown_builtin_kind_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "bogus", "title": "x", "config": {}},
)
assert response.status_code == 422
def test_credential_key_in_config_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Service-bound widget CRUD
# ---------------------------------------------------------------------------
def test_create_service_bound_widget(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dashboard",
"config": {"dashboard_uid": "overview"},
},
)
assert response.status_code == 201
created = response.json()
assert created["service_id"] == service["id"]
assert created["widget_kind"] == "link"
def test_service_bound_widget_unknown_kind_rejected(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {},
},
)
assert response.status_code == 422
def test_service_bound_widget_service_not_found_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"service_id": "missing",
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
)
assert response.status_code == 422
def test_service_bound_widget_invalid_config_rejected(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
},
)
# Empty string passes Pydantic; force a real failure with a bad type.
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": 123},
},
)
assert response.status_code == 422
def test_update_and_delete_widget(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
).json()
updated = client.put(
f"/api/widgets/instances/{created['id']}",
json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
).json()
assert updated["title"] == "Note2"
assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
assert client.get("/api/widgets/instances").json() == []
def test_update_nonexistent_returns_404(client):
response = client.put(
"/api/widgets/instances/missing",
json={"widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 404
def test_update_id_mismatch_returns_400(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {}},
).json()
response = client.put(
f"/api/widgets/instances/{created['id']}",
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 400
# ---------------------------------------------------------------------------
# Data endpoint
# ---------------------------------------------------------------------------
def test_fetch_static_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
body = response.json()
assert body["data"]["text"] == "hello"
assert body["error"] is None
def test_fetch_backups_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
assert "total_jobs" in response.json()["data"]
def test_fetch_grafana_link_widget_data(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dashboard",
"config": {"dashboard_uid": "overview", "panel_id": 2},
},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
def test_fetch_widget_service_not_found(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
# Deleting the service cascade-deletes its widgets, so the widget is gone.
client.delete(f"/api/services/instances/{service['id']}")
assert client.get("/api/widgets/instances").json() == []
assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404
def test_fetch_widget_service_disabled(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
client.put(
f"/api/services/instances/{service['id']}",
json={
"service_type": "grafana",
"name": service["name"],
"config": {"base_url": "https://grafana.example.com"},
"enabled": False,
},
)
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
assert "disabled" in response.json()["error"]
def test_fetch_widget_not_found(client):
assert client.get("/api/widgets/instances/missing/data").status_code == 404
# ---------------------------------------------------------------------------
# Adapter unit tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_grafana_adapter_builds_url():
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
assert result["url"] == "http://g:3000/d/ov"
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
@pytest.mark.asyncio
async def test_grafana_adapter_missing_service():
adapter = GrafanaWidgetSource()
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
assert "error" in result
@pytest.mark.asyncio
async def test_alertmanager_adapter_summarizes_alerts():
adapter = AlertmanagerWidgetSource()
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"status": "success",
"data": [
{
"labels": {"alertname": "DiskFull", "severity": "critical"},
"annotations": {"summary": "disk full"},
"startsAt": "2026-06-23T00:00:00Z",
"status": "firing",
}
],
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "active_alerts", {})
assert result["total"] == 1
assert result["by_severity"]["critical"] == 1
assert result["alerts"][0]["name"] == "DiskFull"
@pytest.mark.asyncio
async def test_alertmanager_adapter_applies_severity_filter():
adapter = AlertmanagerWidgetSource()
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"status": "success",
"data": [
{"labels": {"alertname": "A", "severity": "critical"}, "annotations": {}, "status": "firing"},
{"labels": {"alertname": "B", "severity": "warning"}, "annotations": {}, "status": "firing"},
],
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "active_alerts", {"severity_filter": "critical"})
assert result["total"] == 1
assert result["alerts"][0]["name"] == "A"
@pytest.mark.asyncio
async def test_alertmanager_adapter_sends_bearer_token():
adapter = AlertmanagerWidgetSource()
service = ServiceRecord(
id="s",
service_type="alertmanager",
name="am",
config={"base_url": "http://am:9093"},
secrets={"api_key": "tok"},
)
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []})
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
result = await adapter.fetch(service, "active_alerts", {})
assert result["total"] == 0
_, kwargs = mock_get.call_args
assert kwargs["headers"]["Authorization"] == "Bearer tok"
@pytest.mark.asyncio
async def test_alertmanager_adapter_missing_service():
adapter = AlertmanagerWidgetSource()
result = await adapter.fetch(None, "active_alerts", {})
assert "error" in result
@pytest.mark.asyncio
async def test_static_adapter():
adapter = StaticWidgetSource()
result = await adapter.fetch(None, "static", {"text": "hi"})
assert result == {"text": "hi"}
@pytest.mark.asyncio
async def test_backups_adapter(client):
store = app.dependency_overrides[get_settings_store]()
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
adapter = BackupsWidgetSource()
result = await adapter.fetch(None, "backups", {})
assert "total_jobs" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_missing_service():
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
assert "error" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_records_history_on_run(client):
store = app.dependency_overrides[get_settings_store]()
# Save a task and an ssh_tasks service instance.
task = store.upsert_task(
{
"name": "echo",
"task_type": "shell",
"content": "echo hi",
"enabled": True,
"default_service_id": "",
}
)
service = store.upsert_service(
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
)
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
service_record = ServiceRecord(
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
)
with (
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=fake_client),
):
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
assert result["exit_status"] == 0
runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1
assert runs[0]["status"] == "success"
# ---------------------------------------------------------------------------
# New widget kind tests (jellyfin now_playing + grafana panel)
# ---------------------------------------------------------------------------
def test_jellyfin_definition_has_now_playing_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("jellyfin")
kinds = {wk.kind for wk in definition.widget_kinds}
assert "now_playing" in kinds
assert "activity" in kinds
def test_grafana_definition_has_panel_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("grafana")
kinds = {wk.kind for wk in definition.widget_kinds}
assert "panel" in kinds
assert "link" in kinds
@pytest.mark.asyncio
async def test_grafana_adapter_builds_panel_embed_url():
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(
service,
"panel",
{"dashboard_uid": "ov", "panel_id": 4, "from_ts": "now-6h", "to_ts": "now"},
)
assert "embed_url" in result
assert result["embed_url"] == ("http://g:3000/d-solo/ov/manage?panelId=4&from=now-6h&to=now&kiosk=tv")
@pytest.mark.asyncio
async def test_grafana_adapter_panel_uses_defaults():
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(service, "panel", {"dashboard_uid": "ov", "panel_id": 2})
assert "from=now-1h" in result["embed_url"]
assert "to=now" in result["embed_url"]
@pytest.mark.asyncio
async def test_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
playing_session = {
"UserName": "alice",
"NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
"PlayState": {"IsPaused": False},
"DeviceName": "Web",
}
paused_session = {
"UserName": "bob",
"NowPlayingItem": {"Name": "Show", "Type": "Episode"},
"PlayState": {"IsPaused": True},
"DeviceName": "TV",
}
idle_session = {
"UserName": "carol",
"PlayState": {"IsPaused": False},
"DeviceName": "Phone",
}
mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "now_playing", {})
sessions = result["sessions"]
assert len(sessions) == 1
assert sessions[0]["user"] == "alice"
assert sessions[0]["state"] == "playing"
@pytest.mark.asyncio
async def test_jellyfin_activity_shows_all_sessions():
"""activity (default) should include idle and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
mock_client = SimpleNamespace(
sessions=lambda: [
{"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
{"UserName": "bob", "PlayState": {"IsPaused": False}},
]
)
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "activity", {})
assert len(result["sessions"]) == 2