feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract

This commit is contained in:
Developer
2026-07-09 08:46:20 +00:00
parent e7bd0afdd1
commit 1fb12b8a0a
14 changed files with 831 additions and 81 deletions
+187
View File
@@ -988,3 +988,190 @@ async def test_prometheus_mean_adapter_requires_promql():
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}
# ---------------------------------------------------------------------------
# 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_appends_and_returns_series(tmp_path):
"""Speed kind appends a sample and returns {series} with two labeled series."""
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
real_store = QbittorrentSampleStore(harness)
mock_store_cls.return_value = real_store
result = await adapter.fetch(service, "speed", {})
assert "series" in result
labels = [s["label"] for s in result["series"]]
assert labels == ["download", "upload"]
# The sample just appended 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