feat(service-storage-harness): slice 1 — harness + qbit store + client + integration

ServiceDataHarness (services/service_data.py): lifecycle-only registry of
per-concern storage — DB provisioning, idempotent migrations (ALTER TABLE
duplicate-column-name caught per-statement), cascade_delete(service_id).
QbittorrentSampleStore: append/window/prune (MAX_SAMPLES=120) in
qbittorrent.db. QbittorrentClient: cookie-login Web API client (403 re-login,
/sync/maindata). Integration registered with 3 widget kinds (totals/active/
speed). Harness initialized in main.py lifespan.

Backend: 314 pytest pass (21 new), ruff clean.
This commit is contained in:
Developer
2026-07-09 08:22:36 +00:00
parent 9a251db23c
commit e7bd0afdd1
9 changed files with 685 additions and 1 deletions
+134
View File
@@ -0,0 +1,134 @@
"""Unit tests for the QbittorrentClient."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
import requests
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
class QbittorrentClientTests(unittest.TestCase):
def setUp(self) -> None:
self.client = QbittorrentClient("https://qb.example.com", "admin", "secret", timeout=5)
self.session = MagicMock()
self.client._session = self.session
def _login_response(self, text: str = "Ok.") -> MagicMock:
resp = MagicMock()
resp.text = text
resp.raise_for_status.return_value = None
resp.status_code = 200
return resp
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
resp = MagicMock()
resp.json.return_value = json_data
resp.raise_for_status.return_value = None
resp.status_code = status_code
resp.text = ""
return resp
def test_base_url_appends_api_v2(self) -> None:
c = QbittorrentClient("https://qb.example.com", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_base_url_keeps_existing_api_v2(self) -> None:
c = QbittorrentClient("https://qb.example.com/api/v2", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_base_url_strips_trailing_slash(self) -> None:
c = QbittorrentClient("https://qb.example.com/", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_empty_base_url_raises(self) -> None:
with self.assertRaises(ValueError):
QbittorrentClient("", "u", "p")
def test_empty_username_raises(self) -> None:
with self.assertRaises(ValueError):
QbittorrentClient("https://qb.example.com", "", "p")
def test_login_posts_credentials(self) -> None:
self.session.post.return_value = self._login_response("Ok.")
self.client._login()
self.session.post.assert_called_once()
call_args = self.session.post.call_args
self.assertIn("/auth/login", call_args.args[0])
self.assertEqual(call_args.kwargs["data"], {"username": "admin", "password": "secret"})
self.assertTrue(self.client._logged_in)
def test_login_failure_raises_runtime_error(self) -> None:
self.session.post.return_value = self._login_response("Fails.")
with self.assertRaises(RuntimeError):
self.client._login()
def test_get_auto_logs_in_on_first_call(self) -> None:
"""First _get triggers login, then fetches data."""
self.session.post.return_value = self._login_response("Ok.")
self.session.get.return_value = self._get_response({"server_state": {}, "torrents": {}})
result = self.client._get("/sync/maindata")
self.session.post.assert_called_once() # login happened
self.assertEqual(result, {"server_state": {}, "torrents": {}})
def test_cookie_reuse_does_not_re_login(self) -> None:
"""After login, subsequent _get calls do NOT re-login."""
self.client._logged_in = True # simulate already logged in
self.session.get.return_value = self._get_response({"data": 1})
self.client._get("/some/path")
self.session.post.assert_not_called() # no re-login
def test_403_triggers_re_login(self) -> None:
"""A 403 response triggers re-login and retries the GET."""
self.client._logged_in = True # already logged in from a prior call
forbidden = MagicMock()
forbidden.status_code = 403
ok = self._get_response({"server_state": {}, "torrents": {}})
self.session.get.side_effect = [forbidden, ok]
self.session.post.return_value = self._login_response("Ok.")
result = self.client._get("/sync/maindata")
self.assertEqual(self.session.get.call_count, 2) # initial + retry
self.session.post.assert_called_once() # re-login happened
self.assertEqual(result, {"server_state": {}, "torrents": {}})
def test_maindata_returns_full_payload(self) -> None:
self.client._logged_in = True
payload = {
"server_state": {"dl_info_speed": 12345, "up_info_speed": 6789},
"torrents": {
"abc": {"name": "Movie.mkv", "state": "downloading", "progress": 0.5},
"def": {"name": "Show.mkv", "state": "uploading", "progress": 1.0},
},
}
self.session.get.return_value = self._get_response(payload)
result = self.client.maindata()
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
self.assertEqual(len(result["torrents"]), 2)
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
"""A network error during login propagates as requests exception."""
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
bad_resp = MagicMock()
bad_resp.raise_for_status.side_effect = requests.ConnectionError("refused")
bad_resp.text = ""
mock_session.post.return_value = bad_resp
client = QbittorrentClient("https://qb.example.com", "u", "p")
with self.assertRaises(requests.ConnectionError):
client._login()
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""Tests for ServiceDataHarness lifecycle and QbittorrentSampleStore operations."""
from __future__ import annotations
import pytest
from media_library_viewer_api.services.qbittorrent_store import (
MAX_SAMPLES,
QBITTORRENT_CONCERN,
QbittorrentSampleStore,
)
from media_library_viewer_api.services.service_data import (
ServiceDataHarness,
StorageConcern,
)
# A throwaway concern used to test harness lifecycle in isolation.
_TEST_CONCERN = StorageConcern(
concern_key="test",
db_filename="test.db",
migrations=[
"""
CREATE TABLE IF NOT EXISTS test_items (
id INTEGER PRIMARY KEY,
service_id TEXT NOT NULL,
value TEXT
);
CREATE INDEX IF NOT EXISTS idx_test_service ON test_items(service_id);
"""
],
tables=["test_items"],
)
class TestServiceDataHarnessMigrations:
def test_run_migrations_creates_tables(self, tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
harness.run_migrations()
with harness.connect("test") as conn:
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert "test_items" in tables
def test_migrations_are_idempotent(self, tmp_path):
"""Re-running migrations on an already-migrated DB must not crash."""
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
harness.run_migrations()
harness.run_migrations() # should not raise
def test_alter_table_idempotency(self, tmp_path):
"""ALTER TABLE ADD COLUMN must be silently skipped on re-run."""
concern = StorageConcern(
concern_key="alter_test",
db_filename="alter.db",
migrations=[
"CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)",
"ALTER TABLE items ADD COLUMN extra TEXT DEFAULT ''",
],
tables=["items"],
)
harness = ServiceDataHarness(tmp_path)
harness.register(concern)
harness.run_migrations()
harness.run_migrations() # second run: "duplicate column name" caught
with harness.connect("alter_test") as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(items)").fetchall()}
assert "extra" in cols
class TestServiceDataHarnessCascadeDelete:
def test_cascade_delete_removes_only_matching_service(self, tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
harness.run_migrations()
with harness.connect("test") as conn:
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (1, 'svc-a', 'a1')")
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (2, 'svc-a', 'a2')")
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (3, 'svc-b', 'b1')")
conn.commit()
harness.cascade_delete("svc-a")
with harness.connect("test") as conn:
remaining = conn.execute("SELECT service_id, value FROM test_items ORDER BY id").fetchall()
assert len(remaining) == 1
assert remaining[0][0] == "svc-b"
def test_cascade_delete_skips_missing_concern_db(self, tmp_path):
"""cascade_delete on a concern whose DB file doesn't exist should not crash."""
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
# No run_migrations → DB file doesn't exist
harness.cascade_delete("svc-x") # should not raise
class TestQbittorrentSampleStore:
@pytest.fixture()
def store(self, tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
return QbittorrentSampleStore(harness=harness)
def test_append_and_window(self, store):
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
samples = store.window("svc-1")
assert len(samples) == 3
assert samples[0]["ts"] == 100
assert samples[2]["ts"] == 300
assert samples[1]["dl_speed"] == 600
def test_window_with_since_ts(self, store):
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
samples = store.window("svc-1", since_ts=200)
assert len(samples) == 2
assert samples[0]["ts"] == 200
def test_prune_enforces_max_samples(self, store):
for i in range(MAX_SAMPLES + 10):
store.append("svc-1", ts=i, dl_speed=i, up_speed=i)
samples = store.window("svc-1")
assert len(samples) == MAX_SAMPLES
# The oldest 10 should have been pruned
assert samples[0]["ts"] == 10
assert samples[-1]["ts"] == MAX_SAMPLES + 9
def test_two_services_do_not_cross_contaminate(self, store):
store.append("svc-a", ts=100, dl_speed=500, up_speed=50)
store.append("svc-b", ts=200, dl_speed=600, up_speed=60)
a_samples = store.window("svc-a")
b_samples = store.window("svc-b")
assert len(a_samples) == 1
assert a_samples[0]["dl_speed"] == 500
assert len(b_samples) == 1
assert b_samples[0]["dl_speed"] == 600
+3 -1
View File
@@ -57,7 +57,7 @@ def client(tmp_path):
# ---------------------------------------------------------------------------
def test_registry_contains_seven_service_types():
def test_registry_contains_eight_service_types():
assert set(SERVICE_DEFINITIONS) == {
"prometheus",
"alertmanager",
@@ -66,6 +66,7 @@ def test_registry_contains_seven_service_types():
"ssh_tasks",
"backups",
"authentik",
"qbittorrent",
}
@@ -173,6 +174,7 @@ def test_list_service_types(client):
"jellyfin",
"nextcloud",
"prometheus",
"qbittorrent",
"ssh_tasks",
}