Files
manage/backend/tests/test_widgets.py
T
2026-07-14 15:22:34 +00:00

1235 lines
42 KiB
Python

"""Tests for the dashboard widget system: service-bound + built-in widgets."""
from __future__ import annotations
import time
from types import SimpleNamespace
from typing import Any
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,
JellyfinWidgetSource,
ServiceRecord,
StaticWidgetSource,
)
TEST_KEY = Fernet.generate_key().decode()
PROMQL_REQUIRED_ERROR = "promql is required"
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
yield
@pytest.fixture(autouse=True)
def _clear_qbittorrent_client_cache():
"""Reset the per-service qBittorrent client cache between tests.
QbittorrentWidgetSource reuses one authenticated client per service
(lru_cache) so the SID cookie persists across fetches. Without clearing,
a mock client cached by one test would leak into later tests that patch
QbittorrentClient differently.
"""
from media_library_viewer_api.widgets.sources import _qbittorrent_client
_qbittorrent_client.cache_clear()
yield
_qbittorrent_client.cache_clear()
@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_prometheus_service(client, name="Production Prometheus", **config_overrides):
config = {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"}
config.update(config_overrides)
return client.post(
"/api/services/instances",
json={"service_type": "prometheus", "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_prometheus_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": "metric",
"title": "Dash",
"config": {"promql": "up"},
},
)
# 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_prometheus_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "Metrics",
"config": {"promql": "up"},
},
)
assert response.status_code == 201
created = response.json()
assert created["service_id"] == service["id"]
assert created["widget_kind"] == "metric"
def test_service_bound_widget_unknown_kind_rejected(client):
service = _make_prometheus_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "nonexistent_kind",
"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": "metric",
"title": "x",
"config": {"promql": "up"},
},
)
assert response.status_code == 422
def test_service_bound_widget_invalid_config_rejected(client):
service = _make_prometheus_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {"promql": 123}, # bad type: promql must be a string
},
)
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_widget_service_not_found(client):
service = _make_prometheus_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {"promql": "up"},
},
).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_prometheus_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {"promql": "up"},
},
).json()
client.put(
f"/api/services/instances/{service['id']}",
json={
"service_type": "prometheus",
"name": service["name"],
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
"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_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)
# ---------------------------------------------------------------------------
def test_jellyfin_definition_has_now_playing_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("jellyfin")
assert definition is not None
kinds = {wk.kind for wk in definition.widget_kinds}
assert "now_playing" in kinds
assert "activity" in kinds
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_runs_range_query():
"""GM-106: chart kind hits /api/ds/query and returns {series}."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100, 130], [1.0, 1.0]]},
"schema": {
"fields": [
{"name": "Time"},
{"name": "Value", "labels": {"__name__": "up", "instance": "h:9100"}},
]
},
}
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload) as mock_post:
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
call = mock_post.call_args
assert call.args[0].endswith("/api/ds/query")
body = call.kwargs["json"]
assert body["queries"][0]["expr"] == "up"
assert body["queries"][0]["datasource"]["uid"] == "prometheus"
assert "series" in result
assert result["series"][0]["label"] == "instance=h:9100"
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "chart", {"promql": ""})
assert result == {"error": PROMQL_REQUIRED_ERROR}
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_degrades_on_http_error():
"""GM-103: a connection error returns {error} rather than raising."""
import requests as req_mod
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={
"grafana_url": "http://grafana:3000",
"datasource_uid": "prometheus",
"timeout_seconds": 2,
},
secrets={"grafana_api_key": "key"},
)
with patch(
"media_library_viewer_api.widgets.sources.requests.post",
side_effect=req_mod.ConnectionError("refused"),
):
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
assert "error" in result
assert "failed" in result["error"].lower()
@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
# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------
@pytest.fixture
def widget_ref_client(monkeypatch, request):
"""TestClient with an isolated SettingsStore + encryption key."""
monkeypatch.setenv(
"MANAGE_ENCRYPTION_KEY",
Fernet.generate_key().decode(),
)
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
reset_encryption_key_cache()
import tempfile
from pathlib import Path
store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
store.ensure_defaults()
def get_store_override():
return store
app.dependency_overrides[get_settings_store] = get_store_override
client = TestClient(app)
request.addfinalizer(lambda: app.dependency_overrides.pop(get_settings_store, None))
return client, store
def test_widget_reference_lifecycle(widget_ref_client):
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
client, store = widget_ref_client
# Create a service-bound widget (simulating one on a Prometheus service).
store.upsert_service(
{
"service_type": "prometheus",
"name": "Prometheus",
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
)
service = store.list_services("prometheus")[0]
widget = store.upsert_widget(
{
"service_id": service["id"],
"widget_kind": "chart",
"title": "CPU IOWait",
"config": {"promql": "rate(cpu[5m])", "window": "1h"},
"enabled": True,
"sort_order": 0,
}
)
# Reference it on "main" dashboard.
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "main",
"widget_id": widget["id"],
"sort_order": 5,
},
)
assert resp.status_code == 201
ref = resp.json()
assert ref["dashboard_scope"] == "main"
assert ref["widget_id"] == widget["id"]
ref_id = ref["id"]
# List references for "main" — should include our widget.
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
assert resp.status_code == 200
refs = resp.json()
assert len(refs) == 1
assert refs[0]["widget"]["title"] == "CPU IOWait"
# Delete the reference.
resp = client.delete(f"/api/widgets/references/{ref_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Reference is gone, original widget still exists.
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
assert len(resp.json()) == 0
assert store.get_widget(widget["id"]) is not None
def test_widget_reference_detach(widget_ref_client):
"""Detach clones the widget into a standalone instance and removes the reference."""
client, store = widget_ref_client
store.upsert_service(
{
"service_type": "prometheus",
"name": "Prometheus",
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
)
service = store.list_services("prometheus")[0]
widget = store.upsert_widget(
{
"service_id": service["id"],
"widget_kind": "chart",
"title": "Memory",
"config": {"promql": "mem", "window": "1h"},
"enabled": True,
"sort_order": 0,
}
)
# Reference on "main".
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "main",
"widget_id": widget["id"],
},
)
ref_id = resp.json()["id"]
# Detach.
resp = client.post(f"/api/widgets/references/{ref_id}/detach")
assert resp.status_code == 200
cloned = resp.json()
assert cloned["title"] == "Memory"
assert cloned["widget_kind"] == "chart"
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
assert cloned["config"]["promql"] == "mem"
assert cloned["id"] != widget["id"] # new independent widget
# Reference is gone.
refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
assert len(refs) == 0
# Original still exists.
assert store.get_widget(widget["id"]) is not None
def test_widget_reference_update_sort_order(widget_ref_client):
"""PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
client, store = widget_ref_client
widget_a = store.upsert_widget(
{
"service_id": None,
"widget_kind": "static",
"title": "A",
"config": {"text": "a"},
"enabled": True,
"sort_order": 0,
}
)
widget_b = store.upsert_widget(
{
"service_id": None,
"widget_kind": "static",
"title": "B",
"config": {"text": "b"},
"enabled": True,
"sort_order": 1,
}
)
# Two references on the same dashboard scope.
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "named:test",
"widget_id": widget_a["id"],
"sort_order": 0,
},
)
ref_a = resp.json()
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "named:test",
"widget_id": widget_b["id"],
"sort_order": 1,
},
)
ref_b = resp.json()
# Swap sort orders via PUT (per-dashboard reorder).
resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
assert resp.status_code == 200
assert resp.json()["sort_order"] == 1
resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
assert resp.status_code == 200
assert resp.json()["sort_order"] == 0
# Widget instances themselves are unchanged.
assert store.get_widget(widget_a["id"])["sort_order"] == 0
assert store.get_widget(widget_b["id"])["sort_order"] == 1
# ---------------------------------------------------------------------------
# Prometheus gauge + mean adapter tests (GM-107..GM-108)
# ---------------------------------------------------------------------------
def _grafana_single_frame(values, labels=None, display_name=None):
"""Build a Grafana /api/ds/query frames response for a single series."""
field: dict[str, Any] = {"name": "Value"}
if labels:
field["labels"] = labels
if display_name:
field["config"] = {"displayName": display_name}
return {
"results": {
"A": {
"frames": [
{
"data": {"values": values},
"schema": {"fields": [{"name": "Time"}, field]},
}
]
}
}
}
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_returns_scalar():
"""GM-107: gauge kind hits /api/ds/query and returns {value, thresholds}."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: _grafana_single_frame([[100], [0.75]]),
)
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload) as mock_post:
result = await adapter.fetch(
service,
"gauge",
{"promql": "cpu_usage", "warn_at": 0.8, "crit_at": 0.95, "unit": "%"},
)
call = mock_post.call_args
assert call.args[0].endswith("/api/ds/query")
assert call.kwargs["json"]["queries"][0]["expr"] == "cpu_usage"
assert result["value"] == 0.75
assert result["warn_at"] == 0.8
assert result["crit_at"] == 0.95
assert result["unit"] == "%"
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_rejects_multi_series():
"""GM-107: gauge must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"results": {
"A": {
"frames": [
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
{"data": {"values": [[100], [2.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "gauge", {"promql": "up"})
assert "error" in result
assert "single-series" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "gauge", {"promql": ""})
assert result == {"error": PROMQL_REQUIRED_ERROR}
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_computes_average():
"""GM-108: mean kind averages non-null values over the window."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: _grafana_single_frame([[100, 130, 160], [1.0, 2.0, 3.0]]),
)
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
assert result["value"] == 2.0
assert result["unit"] is None
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_rejects_multi_series():
"""GM-108: mean must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"results": {
"A": {
"frames": [
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
{"data": {"values": [[100], [2.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
assert "error" in result
assert "single-series" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_skips_nan_values():
"""GM-108: NaN values are excluded from the mean computation."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
# Grafana frames shape with NaN — normalize_grafana_frames converts string "NaN" to None
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"results": {
"A": {
"frames": [
{"data": {"values": [[100, 130, 160], [2.0, "NaN", 4.0]]}, "schema": {"fields": [{}, {}]}}
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
assert result["value"] == 3.0
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import MetricSource
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": PROMQL_REQUIRED_ERROR}
# ---------------------------------------------------------------------------
# qBittorrent widget source adapter
# ---------------------------------------------------------------------------
def _fake_qbit_maindata():
"""Return a mock maindata response (server_state + torrents dict)."""
return {
"server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
"torrents": {
"h1": {
"name": "Movie.mkv",
"state": "downloading",
"size": 1000,
"progress": 0.5,
"dlspeed": 500,
"upspeed": 10,
},
"h2": {
"name": "Show.mkv",
"state": "uploading",
"size": 2000,
"progress": 1.0,
"dlspeed": 0,
"upspeed": 100,
},
"h3": {
"name": "Queued",
"state": "queuedDL",
"size": 3000,
"progress": 0.0,
"dlspeed": 0,
"upspeed": 0,
},
"h4": {
"name": "Paused",
"state": "pausedDL",
"size": 4000,
"progress": 0.3,
"dlspeed": 0,
"upspeed": 0,
},
},
}
@pytest.mark.asyncio
async def test_qbittorrent_totals_counts_all_torrents():
"""Totals kind returns count of all listed items + by_state breakdown."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "totals", {})
assert result["total"] == 4
assert result["by_state"]["downloading"] == 1
assert result["by_state"]["uploading"] == 1
assert result["by_state"]["queuedDL"] == 1
assert result["by_state"]["pausedDL"] == 1
@pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only():
"""Active kind returns only downloading/uploading torrents (Q3)."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "active", {})
active = result["torrents"]
assert len(active) == 2
names = [t["name"] for t in active]
assert "Movie.mkv" in names
assert "Show.mkv" in names
# Queued and paused are excluded
assert "Queued" not in names
assert "Paused" not in names
@pytest.mark.asyncio
async def test_qbittorrent_speed_reads_samples_without_polling(tmp_path):
"""Speed kind reads persisted samples and never calls qBittorrent itself."""
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
from media_library_viewer_api.services.service_data import ServiceDataHarness
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
# Isolated harness so we don't pollute the real DB
harness = ServiceDataHarness(base_dir=str(tmp_path))
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-speed",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with (
patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
):
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
# Wire the mock store to a real isolated store and seed headless data.
real_store = QbittorrentSampleStore(harness)
real_store.append("svc-speed", round(time.time()), 500000, 1000)
mock_store_cls.return_value = real_store
result = await adapter.fetch(service, "speed", {})
mock_client.assert_not_called()
assert "series" in result
labels = [s["label"] for s in result["series"]]
assert labels == ["download", "upload"]
# The scheduler-supplied sample should be present.
dl_points = result["series"][0]["points"]
assert len(dl_points) >= 1
# timestamps multiplied by 1000 for JS epoch
assert dl_points[-1]["v"] == 500000
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_service():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
result = await adapter.fetch(None, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_credentials():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080"},
secrets={"username": "", "password": ""},
)
result = await adapter.fetch(service, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_timeout():
"""A timeout returns {error} rather than raising."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
import asyncio as _asyncio
async def _slow(*a, **kw):
await _asyncio.sleep(10)
# Make to_thread hang so wait_for times out
mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
result = await adapter.fetch(service, "totals", {})
assert "error" in result