From e7bd0afdd145bf4f457ead7e758a7007c39e69a8 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 9 Jul 2026 08:22:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(service-storage-harness):=20slice=201=20?= =?UTF-8?q?=E2=80=94=20harness=20+=20qbit=20store=20+=20client=20+=20integ?= =?UTF-8?q?ration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../clients/qbittorrent.py | 80 +++++++++ .../integrations/qbittorrent.py | 68 ++++++++ .../integrations/registry.py | 2 + backend/src/media_library_viewer_api/main.py | 6 + .../services/qbittorrent_store.py | 91 +++++++++++ .../services/service_data.py | 153 ++++++++++++++++++ backend/tests/test_qbittorrent_client.py | 134 +++++++++++++++ backend/tests/test_service_data.py | 148 +++++++++++++++++ backend/tests/test_services.py | 4 +- 9 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 backend/src/media_library_viewer_api/clients/qbittorrent.py create mode 100644 backend/src/media_library_viewer_api/integrations/qbittorrent.py create mode 100644 backend/src/media_library_viewer_api/services/qbittorrent_store.py create mode 100644 backend/src/media_library_viewer_api/services/service_data.py create mode 100644 backend/tests/test_qbittorrent_client.py create mode 100644 backend/tests/test_service_data.py diff --git a/backend/src/media_library_viewer_api/clients/qbittorrent.py b/backend/src/media_library_viewer_api/clients/qbittorrent.py new file mode 100644 index 0000000..b8232c7 --- /dev/null +++ b/backend/src/media_library_viewer_api/clients/qbittorrent.py @@ -0,0 +1,80 @@ +"""Minimal qBittorrent Web API client (read-only: sync/maindata only). + +Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s +session pattern. Authentication uses username/password login which stores an +SID cookie in the requests session. The client re-logins transparently on 403. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import requests + +logger = logging.getLogger(__name__) + + +class QbittorrentClient: + """Small wrapper around the qBittorrent Web API. + + Only the endpoints needed by the dashboard widgets are implemented + (currently just ``/sync/maindata``). All calls share a single + :class:`requests.Session` that carries the login cookie. + """ + + def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None: + if not base_url: + raise ValueError("qBittorrent base_url is required") + if not username: + raise ValueError("qBittorrent username is required") + + self.base_url = base_url.rstrip("/") + if not self.base_url.endswith("/api/v2"): + self.base_url += "/api/v2" + self._username = username + self._password = password + self.timeout = timeout + self._session = requests.Session() + self._logged_in = False + + def _login(self) -> None: + """POST username/password to ``/auth/login``; store the SID cookie. + + qBittorrent returns the plain text ``"Ok."`` on success. The + ``Referer`` header is required by some qBittorrent CSRF protections. + """ + resp = self._session.post( + f"{self.base_url}/auth/login", + data={"username": self._username, "password": self._password}, + timeout=self.timeout, + headers={"Referer": self.base_url}, + ) + resp.raise_for_status() + if resp.text.strip() != "Ok.": + raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}") + self._logged_in = True + logger.info("qBittorrent login successful for %s", self.base_url) + + def _get(self, path: str, **params: Any) -> dict[str, Any]: + """GET an endpoint with auto-login on first call and re-login on 403.""" + if not self._logged_in: + self._login() + url = f"{self.base_url}{path}" + resp = self._session.get(url, params=params, timeout=self.timeout) + if resp.status_code == 403: + logger.debug("qBittorrent 403 on %s, re-logging in", path) + self._logged_in = False + self._login() + resp = self._session.get(url, params=params, timeout=self.timeout) + resp.raise_for_status() + return resp.json() + + def maindata(self) -> dict[str, Any]: + """Fetch ``/sync/maindata``. + + Returns a dict with ``server_state`` (containing ``dl_info_speed``, + ``up_info_speed``, etc.) and ``torrents`` (a dict of + ``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``). + """ + return self._get("/sync/maindata") diff --git a/backend/src/media_library_viewer_api/integrations/qbittorrent.py b/backend/src/media_library_viewer_api/integrations/qbittorrent.py new file mode 100644 index 0000000..e505534 --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/qbittorrent.py @@ -0,0 +1,68 @@ +"""qBittorrent service definition. + +Declares the config model (base URL + timeout), secret fields (username + +password), and three widget kinds (totals, active, speed). Models on +:mod:`media_library_viewer_api.integrations.prometheus`. +""" + +from __future__ import annotations + +from media_library_viewer_api.integrations.base import ( + SecretField, + ServiceBaseUrl, + ServiceConfigBase, + ServiceDefinition, + WidgetConfigBase, + widget_kind, +) + + +class QbittorrentConfig(ServiceConfigBase): + """Non-secret qBittorrent connection config.""" + + base_url: ServiceBaseUrl + timeout_seconds: int = 10 + + +class QbittorrentWidgetConfig(WidgetConfigBase): + """Per-widget config (empty — all three kinds derive from the service connection).""" + + pass + + +DEFINITION = ServiceDefinition( + service_type="qbittorrent", + name="qBittorrent", + description="Torrent client activity, speeds, and item counts.", + config_model=QbittorrentConfig, + secret_fields=[ + SecretField(key="username", label="Username", required=True), + SecretField(key="password", label="Password", required=True, helper="Stored encrypted"), + ], + widget_kinds=[ + widget_kind( + kind="totals", + name="Totals", + description="Count of all listed torrents, broken down by state.", + model_cls=QbittorrentWidgetConfig, + default_config={}, + refresh_interval_ms=30_000, + ), + widget_kind( + kind="active", + name="Active torrents", + description="Torrents currently downloading or uploading.", + model_cls=QbittorrentWidgetConfig, + default_config={}, + refresh_interval_ms=15_000, + ), + widget_kind( + kind="speed", + name="Speed chart", + description="Live download/upload speed over a short window.", + model_cls=QbittorrentWidgetConfig, + default_config={}, + refresh_interval_ms=5_000, + ), + ], +) diff --git a/backend/src/media_library_viewer_api/integrations/registry.py b/backend/src/media_library_viewer_api/integrations/registry.py index 3ffb46f..6e6b64e 100644 --- a/backend/src/media_library_viewer_api/integrations/registry.py +++ b/backend/src/media_library_viewer_api/integrations/registry.py @@ -13,6 +13,7 @@ from media_library_viewer_api.integrations.base import ServiceDefinition, Widget from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS +from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { @@ -20,6 +21,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { ALERTMANAGER.service_type: ALERTMANAGER, JELLYFIN.service_type: JELLYFIN, NEXTCLOUD.service_type: NEXTCLOUD, + QBITTORRENT.service_type: QBITTORRENT, SSH_TASKS.service_type: SSH_TASKS, BACKUPS.service_type: BACKUPS, AUTHENTIK.service_type: AUTHENTIK, diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 6ec2373..66fd217 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -52,6 +52,12 @@ async def lifespan(app: FastAPI): get_settings_store().ensure_defaults() except Exception: logger.exception("Failed to seed default settings during startup") + try: + from media_library_viewer_api.services.service_data import get_service_data_harness + + get_service_data_harness() + except Exception: + logger.exception("Failed to initialize service data harness during startup") mail_queue = get_mail_queue() backup_poller = get_backup_poller() mail_queue.start() diff --git a/backend/src/media_library_viewer_api/services/qbittorrent_store.py b/backend/src/media_library_viewer_api/services/qbittorrent_store.py new file mode 100644 index 0000000..28d85ae --- /dev/null +++ b/backend/src/media_library_viewer_api/services/qbittorrent_store.py @@ -0,0 +1,91 @@ +"""Speed-sample storage for qBittorrent widgets. + +This module defines the storage concern for qBittorrent speed data and a +bespoke store with ``append``/``window`` operations. It is registered with the +:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as +the first real consumer of the harness lifecycle layer. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from media_library_viewer_api.services.service_data import StorageConcern + +if TYPE_CHECKING: + from media_library_viewer_api.services.service_data import ServiceDataHarness + +logger = logging.getLogger(__name__) + + +QBITTORRENT_CONCERN = StorageConcern( + concern_key="qbittorrent", + db_filename="qbittorrent.db", + migrations=[ + """ + CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples ( + service_id TEXT NOT NULL, + ts INTEGER NOT NULL, + dl_speed INTEGER NOT NULL DEFAULT 0, + up_speed INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts + ON qbittorrent_speed_samples(service_id, ts); + """ + ], + tables=["qbittorrent_speed_samples"], +) + +#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll). +MAX_SAMPLES = 120 + + +class QbittorrentSampleStore: + """Bespoke speed-sample store for qBittorrent widgets. + + Each ``append`` inserts a new sample and prunes entries beyond + :data:`MAX_SAMPLES`, keeping only the most recent rows for the given + ``service_id``. + """ + + def __init__(self, harness: ServiceDataHarness | None = None) -> None: + if harness is None: + from media_library_viewer_api.services.service_data import get_service_data_harness + + harness = get_service_data_harness() + self._harness = harness + + def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None: + """Append a sample and prune old entries beyond ``MAX_SAMPLES``.""" + with self._harness.connect("qbittorrent") as conn: + conn.execute( + "INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)", + (service_id, ts, dl_speed, up_speed), + ) + conn.execute( + "DELETE FROM qbittorrent_speed_samples " + "WHERE service_id = ? AND ts NOT IN (" + " SELECT ts FROM qbittorrent_speed_samples" + " WHERE service_id = ?" + " ORDER BY ts DESC LIMIT ?" + ")", + (service_id, service_id, MAX_SAMPLES), + ) + conn.commit() + + def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]: + """Return all samples for a service since a timestamp (or all if ``None``).""" + with self._harness.connect("qbittorrent") as conn: + if since_ts is not None: + rows = conn.execute( + "SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples " + "WHERE service_id = ? AND ts >= ? ORDER BY ts ASC", + (service_id, since_ts), + ).fetchall() + else: + rows = conn.execute( + "SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC", + (service_id,), + ).fetchall() + return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows] diff --git a/backend/src/media_library_viewer_api/services/service_data.py b/backend/src/media_library_viewer_api/services/service_data.py new file mode 100644 index 0000000..9dbc596 --- /dev/null +++ b/backend/src/media_library_viewer_api/services/service_data.py @@ -0,0 +1,153 @@ +"""Lifecycle-only storage harness for service-owned persistent data. + +This module owns the *lifecycle* of per-concern SQLite databases: provisioning, +schema migrations, and cascade-delete when a service instance is removed. It +does **not** own data operations — each integration implements its own Store +with bespoke operations (``append``/``window``, ``replace_items``/``query``, +etc.). This keeps the general interface narrow (lifecycle) and the specific +interfaces rich (per-integration operations). + +Each storage *concern* is registered with a :class:`StorageConcern` dataclass +declaring its DB filename, ordered migration statements, owned tables, and the +column used for service scoping. +""" + +from __future__ import annotations + +import logging +import os +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class StorageConcern: + """A per-integration storage namespace registered with the harness.""" + + concern_key: str # e.g. "qbittorrent", "media_index" + db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite" + migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch) + tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade) + service_id_column: str = "service_id" + + +class ServiceDataHarness: + """Lifecycle-only registry of per-concern storage. + + Owns: + * DB provisioning (per-concern SQLite files under ``base_dir``). + * Schema migrations (run on first access via :meth:`run_migrations`). + * ``service_id`` cascade-delete when a service instance is removed. + + Does **not** own: + * Data operations — each store keeps bespoke append/window/query/etc. + * A generic value table or generic CRUD layer. + """ + + def __init__(self, base_dir: Path | str) -> None: + self._base_dir = Path(base_dir) + self._concerns: dict[str, StorageConcern] = {} + + @property + def base_dir(self) -> Path: + return self._base_dir + + def register(self, concern: StorageConcern) -> None: + """Register a storage concern. Called at startup / on first access.""" + self._concerns[concern.concern_key] = concern + + def db_path(self, concern_key: str) -> Path: + """Return the absolute path to a concern's DB file.""" + concern = self._concerns[concern_key] + return self._base_dir / concern.db_filename + + def connect(self, concern_key: str) -> sqlite3.Connection: + """Open a WAL-mode connection to a concern's DB.""" + path = self.db_path(concern_key) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + return conn + + def run_migrations(self) -> None: + """Run pending migrations for every registered concern. + + Each migration string is split into individual statements (by ``;``) + and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements + that fail with "duplicate column name" are silently skipped, making + migrations idempotent across re-runs and fresh installs where + ``init_schema`` may have already created the column. + """ + for concern in self._concerns.values(): + path = self.db_path(concern.concern_key) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=30) + try: + conn.execute("PRAGMA journal_mode=WAL") + for migration_sql in concern.migrations: + statements = [s.strip() for s in migration_sql.split(";") if s.strip()] + for stmt in statements: + try: + conn.execute(stmt) + except sqlite3.OperationalError as exc: + if "duplicate column name" in str(exc).lower(): + logger.debug("Skipping already-applied migration: %s", stmt[:80]) + else: + raise + conn.commit() + finally: + conn.close() + + def cascade_delete(self, service_id: str) -> None: + """Delete all rows for a ``service_id`` across every concern's tables. + + Called from :meth:`SettingsStore.delete_service` after the service row + is removed. Best-effort: callers wrap in try/except so a harness + failure does not block service deletion. + """ + for concern in self._concerns.values(): + col = concern.service_id_column + path = self.db_path(concern.concern_key) + if not path.exists(): + continue + with sqlite3.connect(path, timeout=30) as conn: + for table in concern.tables: + cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + if col in cols: + conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,)) + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_HARNESS: ServiceDataHarness | None = None + + +def get_service_data_harness() -> ServiceDataHarness: + """Return the process-wide harness singleton, initializing it on first call. + + Lazy registration of built-in concerns happens here (local imports avoid + circular dependencies). Migrations are run immediately after registration. + """ + global _HARNESS + if _HARNESS is None: + base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer")) + _HARNESS = ServiceDataHarness(base_dir) + # Register built-in concerns (lazy import avoids circular dependency). + from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN + + _HARNESS.register(QBITTORRENT_CONCERN) + _HARNESS.run_migrations() + return _HARNESS + + +def reset_service_data_harness() -> None: + """Reset the singleton (for testing).""" + global _HARNESS + _HARNESS = None diff --git a/backend/tests/test_qbittorrent_client.py b/backend/tests/test_qbittorrent_client.py new file mode 100644 index 0000000..0b22878 --- /dev/null +++ b/backend/tests/test_qbittorrent_client.py @@ -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() diff --git a/backend/tests/test_service_data.py b/backend/tests/test_service_data.py new file mode 100644 index 0000000..d067ccc --- /dev/null +++ b/backend/tests/test_service_data.py @@ -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 diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index bb67f79..95707ff 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -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", }