200d319fb0
Introduce a closed, compile-time widget registry and backend CRUD for dashboard widget instances. - Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and default seeding (Jellyfin + Backups) on first install. - Add Pydantic models with credential-key and secret-value rejection. - Add widgets router: /api/widgets/sources, /types, /instances CRUD. - Call ensure_defaults() in app lifespan so fresh installs seed defaults. - Add backend tests covering registry, CRUD, validation, and seeding. - Include SDD artifacts: exploration, proposal, spec, design, tasks.
292 lines
7.7 KiB
Python
292 lines
7.7 KiB
Python
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import 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
|
|
|
|
|
|
@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
|