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
@@ -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")
@@ -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,
),
],
)
@@ -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,
@@ -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()
@@ -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]
@@ -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
+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",
}