feat: add typed qBittorrent scheduled polling
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Unit tests for typed scheduled actions and scheduler persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
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.qbittorrent_store import (
|
||||
QBITTORRENT_CONCERN,
|
||||
QbittorrentSampleStore,
|
||||
)
|
||||
from media_library_viewer_api.services.scheduler import ( # type: ignore[reportMissingImports]
|
||||
Scheduler,
|
||||
SchedulerBusyError,
|
||||
)
|
||||
from media_library_viewer_api.services.scheduler_actions import ( # type: ignore[reportMissingImports]
|
||||
QBITTORRENT_SPEED_ACTION,
|
||||
ActionResult,
|
||||
)
|
||||
from media_library_viewer_api.services.scheduler_store import ( # type: ignore[reportMissingImports]
|
||||
SCHEDULER_CONCERN,
|
||||
SchedulerRunStore,
|
||||
)
|
||||
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
reset_encryption_key_cache()
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
app.dependency_overrides[get_settings_store] = lambda: store
|
||||
with patch("media_library_viewer_api.auth.get_settings", return_value=SimpleNamespace(auth_enabled=False)):
|
||||
yield TestClient(app), store
|
||||
app.dependency_overrides.clear()
|
||||
reset_encryption_key_cache()
|
||||
|
||||
|
||||
def _service(service_id: str = "svc-1") -> ServiceRecord:
|
||||
return ServiceRecord(
|
||||
id=service_id,
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={
|
||||
"poll_interval_seconds": 15,
|
||||
"sample_retention_seconds": 1_800,
|
||||
"sample_max_rows": 1200,
|
||||
},
|
||||
secrets={},
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_routes_expose_status_history_and_disabled_manual_run(scheduler_client):
|
||||
client, store = scheduler_client
|
||||
service = store.upsert_service(
|
||||
{
|
||||
"service_type": "qbittorrent",
|
||||
"name": "qbit",
|
||||
"config": {"base_url": "http://qbit:8080", "polling_enabled": False},
|
||||
"secrets": {},
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
status = client.get(f"/api/scheduler/services/{service['id']}/status")
|
||||
assert status.status_code == 200
|
||||
assert not status.json()["enabled"]
|
||||
|
||||
runs = client.get(f"/api/scheduler/services/{service['id']}/runs")
|
||||
assert runs.status_code == 200
|
||||
assert runs.json()["items"] == []
|
||||
|
||||
manual = client.post(f"/api/scheduler/services/{service['id']}/run")
|
||||
assert manual.status_code == 400
|
||||
|
||||
|
||||
def test_sample_store_applies_time_and_row_limits(tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(QBITTORRENT_CONCERN)
|
||||
harness.run_migrations()
|
||||
store = QbittorrentSampleStore(harness)
|
||||
|
||||
now = round(time.time())
|
||||
for index in range(70):
|
||||
store.append(
|
||||
"svc-1",
|
||||
now - 20 + index,
|
||||
index,
|
||||
index,
|
||||
retention_seconds=60,
|
||||
max_rows=60,
|
||||
)
|
||||
|
||||
samples = store.window("svc-1")
|
||||
assert len(samples) == 60
|
||||
assert samples[0]["ts"] == now - 10
|
||||
|
||||
|
||||
def test_scheduler_records_success_and_manual_run_resets_backoff(tmp_path, monkeypatch):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(SCHEDULER_CONCERN)
|
||||
harness.run_migrations()
|
||||
scheduler = Scheduler()
|
||||
scheduler._run_store = SchedulerRunStore(harness)
|
||||
service = _service()
|
||||
|
||||
class Action:
|
||||
def run(self, value):
|
||||
assert value.id == "svc-1"
|
||||
return ActionResult(data={"ok": True})
|
||||
|
||||
with patch("media_library_viewer_api.services.scheduler.get_scheduled_action", return_value=Action()):
|
||||
result = scheduler._execute(service, "manual")
|
||||
|
||||
assert result["status"] == "success"
|
||||
fake_store = cast(
|
||||
SettingsStore,
|
||||
SimpleNamespace(get_service=lambda service_id: {"id": service_id, "service_type": "qbittorrent", "config": {}}),
|
||||
)
|
||||
status = scheduler.status("svc-1", store=fake_store)
|
||||
assert status["consecutive_failures"] == 0
|
||||
assert status["backoff_until"] is None
|
||||
runs, total = scheduler._run_store.list_runs("svc-1", QBITTORRENT_SPEED_ACTION)
|
||||
assert total == 1
|
||||
assert runs[0]["trigger"] == "manual"
|
||||
|
||||
|
||||
def test_scheduler_rejects_overlapping_manual_runs(tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(SCHEDULER_CONCERN)
|
||||
harness.run_migrations()
|
||||
scheduler = Scheduler()
|
||||
scheduler._run_store = SchedulerRunStore(harness)
|
||||
service = _service()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
class SlowAction:
|
||||
def run(self, value):
|
||||
started.set()
|
||||
release.wait(2)
|
||||
return ActionResult(data={})
|
||||
|
||||
with patch("media_library_viewer_api.services.scheduler.get_scheduled_action", return_value=SlowAction()):
|
||||
worker = threading.Thread(target=scheduler._execute, args=(service, "manual"))
|
||||
worker.start()
|
||||
assert started.wait(1)
|
||||
try:
|
||||
try:
|
||||
scheduler._execute(service, "manual")
|
||||
except SchedulerBusyError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected overlapping run to be rejected")
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(timeout=2)
|
||||
|
||||
|
||||
def test_scheduler_status_marks_never_run_service_stale():
|
||||
scheduler = Scheduler()
|
||||
store = cast(
|
||||
SettingsStore,
|
||||
SimpleNamespace(
|
||||
get_service=lambda service_id: {
|
||||
"id": service_id,
|
||||
"service_type": "qbittorrent",
|
||||
"enabled": True,
|
||||
"config": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
status = scheduler.status("svc-1", store=store)
|
||||
assert bool(status["is_stale"])
|
||||
assert status["poll_interval_seconds"] == 15
|
||||
Reference in New Issue
Block a user