787f46700f
The build endpoint had Depends(get_jellyfin_client) and Depends(get_user_id) which executed BEFORE the function body. If Jellyfin was unreachable, these raised HTTPException(503), the function never ran, and the worker was never started. The frontend mutation had no onError handler, so the failure was completely silent — the button briefly showed 'Building...' then reverted to 'Build index' with zero feedback. Backend fix: removed the Jellyfin dependencies from post_build_index. The worker subprocess resolves its own Jellyfin connection via _resolve_jellyfin(service_id) — the endpoint just needs to start the worker process. The libraries count starts at 0 and gets updated by the worker once it connects. Frontend fix: added onError to useBuildIndex that invalidates the status query (so the UI reflects the non-building state). MediaTab now displays the build error inline: 'Build failed: <message>' next to the button. 283 backend tests pass (updated build test for new no-dependency flow); 128 frontend tests pass; ruff/eslint clean.
827 lines
33 KiB
Python
827 lines
33 KiB
Python
"""Integration tests for FastAPI endpoints using TestClient.
|
|
|
|
These tests mock the SSH and Jellyfin clients to test the API layer
|
|
without requiring real remote connections.
|
|
"""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from media_library_viewer_api.clients.ssh import CommandResult
|
|
from media_library_viewer_api.dependencies import (
|
|
get_jellyfin_client,
|
|
get_settings_store,
|
|
get_ssh_client,
|
|
get_user_id,
|
|
)
|
|
from media_library_viewer_api.main import app
|
|
from media_library_viewer_api.routers.media import get_media_index
|
|
from media_library_viewer_api.services.media_index import MediaIndex
|
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
|
|
|
# Short alias for the monitoring router module under test.
|
|
_MON = "media_library_viewer_api.routers.monitoring"
|
|
_SVC = "media_library_viewer_api.services.service_resolution"
|
|
|
|
# --- Fixtures ---
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_jellyfin():
|
|
"""Mock Jellyfin client."""
|
|
client = MagicMock()
|
|
client.media_counts.return_value = {"movies": 100, "series": 20, "episodes": 500}
|
|
client.libraries.return_value = [
|
|
{"Id": "lib1", "Name": "Movies", "CollectionType": "movies"},
|
|
{"Id": "lib2", "Name": "TV Shows", "CollectionType": "tvshows"},
|
|
]
|
|
client.users.return_value = [
|
|
{"Id": "jf1", "Name": "alex"},
|
|
{"Id": "jf2", "Name": "sam"},
|
|
]
|
|
client.library_item_counts.return_value = [
|
|
{"library": "Movies", "type": "movies", "movies": 100, "series": 0, "episodes": 0, "total": 100},
|
|
{"library": "TV Shows", "type": "tvshows", "movies": 0, "series": 20, "episodes": 500, "total": 520},
|
|
]
|
|
client.sessions.return_value = [
|
|
{
|
|
"Id": "sess1",
|
|
"UserName": "alex",
|
|
"DeviceName": "Chrome",
|
|
"NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"},
|
|
"PlayState": {"IsPaused": False},
|
|
"TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False},
|
|
},
|
|
{
|
|
"Id": "sess2",
|
|
"UserName": "sam",
|
|
"DeviceName": "Android",
|
|
"NowPlayingItem": None,
|
|
"PlayState": {},
|
|
"TranscodingInfo": None,
|
|
},
|
|
]
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ssh():
|
|
"""Mock SSH client."""
|
|
client = MagicMock()
|
|
client.host = "test-host"
|
|
# Default list_dir response
|
|
entries = [
|
|
{"type": "d", "size": 4096, "mtime": 1700000000, "name": "Movies"},
|
|
{"type": "f", "size": 5000000000, "mtime": 1700000100, "name": "movie.mkv"},
|
|
]
|
|
client.list_dir.return_value = CommandResult(
|
|
command="find ...",
|
|
exit_status=0,
|
|
stdout=json.dumps(entries),
|
|
stderr="",
|
|
)
|
|
client.stat_path.return_value = CommandResult(
|
|
command="stat ...",
|
|
exit_status=0,
|
|
stdout="regular file\n5000000000 bytes\n2024-01-01\n/path",
|
|
stderr="",
|
|
)
|
|
client.ffprobe_json.return_value = {
|
|
"format": {"filename": "movie.mkv", "format_name": "matroska"},
|
|
"streams": [{"codec_type": "video", "codec_name": "hevc"}],
|
|
}
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client(mock_jellyfin, mock_ssh, tmp_path):
|
|
"""FastAPI test client with mocked dependencies."""
|
|
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
|
|
app.dependency_overrides[get_ssh_client] = lambda: mock_ssh
|
|
app.dependency_overrides[get_user_id] = lambda: "user123"
|
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
|
app.dependency_overrides[get_settings_store] = lambda: store
|
|
auth_settings = SimpleNamespace(auth_enabled=False)
|
|
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
|
|
client = TestClient(app)
|
|
yield client
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# --- Health ---
|
|
|
|
|
|
class TestHealth:
|
|
def test_health(self, test_client):
|
|
response = test_client.get("/api/health")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
def test_metrics_endpoint(self, test_client):
|
|
response = test_client.get("/metrics")
|
|
assert response.status_code == 200
|
|
assert "manage_api_requests_total" in response.text
|
|
assert response.headers["content-type"].startswith("text/plain")
|
|
|
|
def test_request_id_header(self, test_client):
|
|
response = test_client.get("/api/health")
|
|
assert response.status_code == 200
|
|
assert "x-request-id" in response.headers
|
|
assert len(response.headers["x-request-id"]) > 0
|
|
|
|
|
|
# --- Dashboard ---
|
|
|
|
|
|
class TestDashboard:
|
|
def test_counts(self, test_client):
|
|
response = test_client.get("/api/dashboard/counts")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["movies"] == 100
|
|
assert data["series"] == 20
|
|
assert data["episodes"] == 500
|
|
|
|
def test_libraries(self, test_client):
|
|
response = test_client.get("/api/dashboard/libraries")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 2
|
|
assert data[0]["library"] == "Movies"
|
|
assert data[1]["library"] == "TV Shows"
|
|
|
|
def test_activity(self, test_client):
|
|
response = test_client.get("/api/dashboard/activity")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 2
|
|
|
|
playing_row = next(row for row in data if row["user"] == "alex")
|
|
assert playing_row["title"] == "Test Movie"
|
|
assert playing_row["state"] == "playing"
|
|
assert playing_row["transcoding"] == "yes"
|
|
assert "audio" in playing_row["transcoding_type"]
|
|
|
|
idle_row = next(row for row in data if row["user"] == "sam")
|
|
assert idle_row["state"] == "idle"
|
|
assert idle_row["title"] == "(idle)"
|
|
|
|
def test_now_playing_alias(self, test_client):
|
|
response = test_client.get("/api/dashboard/now-playing")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 2
|
|
|
|
def test_jellyfin_endpoints_return_503_when_not_configured(self, test_client):
|
|
# Remove the mocked Jellyfin dependency so the real one runs; with no
|
|
# Jellyfin service seeded, endpoints must degrade to 503, not 500.
|
|
app.dependency_overrides.pop(get_jellyfin_client, None)
|
|
app.dependency_overrides.pop(get_user_id, None)
|
|
try:
|
|
for path in ("/api/dashboard/counts", "/api/dashboard/activity"):
|
|
response = test_client.get(path)
|
|
assert response.status_code == 503, path
|
|
detail = response.json()["detail"]
|
|
assert "configured" in detail, path
|
|
finally:
|
|
# Restore the mocks for subsequent tests in this fixture session.
|
|
app.dependency_overrides[get_jellyfin_client] = lambda: MagicMock()
|
|
app.dependency_overrides[get_user_id] = lambda: "user123"
|
|
|
|
|
|
# --- Settings reset ---
|
|
|
|
|
|
class TestSettingsReset:
|
|
def test_reset_local_database_requires_full_confirmation(self, test_client, tmp_path):
|
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
|
store.ensure_defaults()
|
|
media_db = tmp_path / "media.sqlite"
|
|
media_db.write_text("placeholder", encoding="utf-8")
|
|
media_wal = tmp_path / "media.sqlite-wal"
|
|
media_wal.write_text("wal", encoding="utf-8")
|
|
|
|
app.dependency_overrides[get_settings_store] = lambda: store
|
|
settings_module = __import__("media_library_viewer_api.routers.settings", fromlist=["MediaIndex"])
|
|
original_media_index = settings_module.MediaIndex
|
|
settings_module.MediaIndex = lambda: SimpleNamespace(db_path=media_db)
|
|
try:
|
|
response = test_client.post(
|
|
"/api/settings/reset-local-database",
|
|
json={
|
|
"confirm_phrase": "RESET LOCAL DATABASE",
|
|
"acknowledge_settings_loss": True,
|
|
"acknowledge_media_index_loss": False,
|
|
"acknowledge_irreversible": True,
|
|
},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(get_settings_store, None)
|
|
settings_module.MediaIndex = original_media_index
|
|
assert response.status_code == 400
|
|
|
|
def test_reset_local_database_wipes_state_and_leaves_no_machines(self, test_client, tmp_path):
|
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
|
store.ensure_defaults()
|
|
media_db = tmp_path / "media.sqlite"
|
|
media_db.write_text("placeholder", encoding="utf-8")
|
|
media_wal = tmp_path / "media.sqlite-wal"
|
|
media_wal.write_text("wal", encoding="utf-8")
|
|
|
|
app.dependency_overrides[get_settings_store] = lambda: store
|
|
settings_module = __import__("media_library_viewer_api.routers.settings", fromlist=["MediaIndex"])
|
|
original_media_index = settings_module.MediaIndex
|
|
settings_module.MediaIndex = lambda: SimpleNamespace(db_path=media_db)
|
|
try:
|
|
response = test_client.post(
|
|
"/api/settings/reset-local-database",
|
|
json={
|
|
"confirm_phrase": "RESET LOCAL DATABASE",
|
|
"acknowledge_settings_loss": True,
|
|
"acknowledge_media_index_loss": True,
|
|
"acknowledge_irreversible": True,
|
|
},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(get_settings_store, None)
|
|
settings_module.MediaIndex = original_media_index
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["status"] == "reset"
|
|
assert not media_db.exists()
|
|
assert not media_wal.exists()
|
|
assert store.get_machine("local") is None
|
|
assert len(store.list_machines()) == 0
|
|
|
|
|
|
# --- Files ---
|
|
|
|
|
|
class TestFiles:
|
|
def test_list_directory(self, test_client):
|
|
response = test_client.get("/api/files/list", params={"path": "/media"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["path"] == "/media"
|
|
assert data["count"] == 2
|
|
assert data["entries"][0]["name"] == "Movies"
|
|
|
|
def test_ffprobe(self, test_client):
|
|
response = test_client.get("/api/files/ffprobe", params={"path": "/media/movie.mkv"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["format"]["format_name"] == "matroska"
|
|
assert data["streams"][0]["codec_name"] == "hevc"
|
|
|
|
def test_stat(self, test_client):
|
|
response = test_client.get("/api/files/stat", params={"path": "/media/movie.mkv"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["path"] == "/media/movie.mkv"
|
|
assert "bytes" in data["output"]
|
|
|
|
def test_resolve_path(self, test_client):
|
|
response = test_client.get("/api/files/resolve-path", params={"path": "/media/shows/X"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["original"] == "/media/shows/X"
|
|
assert "resolved" in data
|
|
|
|
def test_list_failure(self, test_client, mock_ssh):
|
|
mock_ssh.list_dir.return_value = CommandResult(
|
|
command="find ...", exit_status=1, stdout="", stderr="Permission denied"
|
|
)
|
|
response = test_client.get("/api/files/list", params={"path": "/root"})
|
|
assert response.status_code == 400
|
|
assert "Permission denied" in response.text
|
|
|
|
|
|
# --- Media index build ---
|
|
|
|
|
|
class TestMediaIndexApi:
|
|
def test_status_includes_build_progress(self, test_client, tmp_path):
|
|
index = MediaIndex(tmp_path / "index.sqlite")
|
|
index.init_schema()
|
|
index.set_metadata("build_running", "true")
|
|
index.set_metadata("build_stage", "building")
|
|
index.set_metadata("build_message", "3 / 10 items")
|
|
index.set_metadata("build_progress", "0.3")
|
|
index.set_metadata("build_items_processed", "3")
|
|
index.set_metadata("build_items_total", "10")
|
|
index.set_metadata("build_current_library", "Movies")
|
|
index.set_metadata("build_library_index", "1")
|
|
index.set_metadata("build_libraries_total", "2")
|
|
index.set_metadata("build_library_progress", "0.5")
|
|
index.set_metadata("build_library_items_processed", "1")
|
|
index.set_metadata("build_library_items_total", "2")
|
|
index.set_metadata("build_elapsed_seconds", "12.0")
|
|
index.set_metadata("build_eta_seconds", "28.0")
|
|
index.set_metadata("build_library_elapsed_seconds", "4.0")
|
|
index.set_metadata("build_library_eta_seconds", "4.0")
|
|
index.set_metadata("build_pid", "4321")
|
|
app.dependency_overrides[get_media_index] = lambda: index
|
|
with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True):
|
|
response = test_client.get("/api/media/status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["build_running"] is True
|
|
assert data["build_stage"] == "building"
|
|
assert data["build_message"] == "3 / 10 items"
|
|
assert data["build_progress"] == 0.3
|
|
assert data["build_items_processed"] == 3
|
|
assert data["build_items_total"] == 10
|
|
assert data["build_current_library"] == "Movies"
|
|
assert data["build_library_index"] == 1
|
|
assert data["build_libraries_total"] == 2
|
|
assert data["build_library_progress"] == 0.5
|
|
assert data["build_library_items_processed"] == 1
|
|
assert data["build_library_items_total"] == 2
|
|
assert data["build_elapsed_seconds"] == 12.0
|
|
assert data["build_eta_seconds"] == 28.0
|
|
assert data["build_library_elapsed_seconds"] == 4.0
|
|
assert data["build_library_eta_seconds"] == 4.0
|
|
assert data["build_pid"] == 4321
|
|
|
|
def test_build_returns_started_when_background_build_is_queued(self, test_client, tmp_path, mock_jellyfin):
|
|
index = MediaIndex(tmp_path / "index.sqlite")
|
|
app.dependency_overrides[get_media_index] = lambda: index
|
|
|
|
class FakeProcess:
|
|
pid = 4321
|
|
|
|
with patch("media_library_viewer_api.routers.media._start_worker", return_value=FakeProcess()) as start_worker:
|
|
try:
|
|
response = test_client.post("/api/media/build")
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "started"
|
|
assert data["build_running"] is True
|
|
assert data["build_stage"] == "queued"
|
|
assert data["build_libraries_total"] == 0
|
|
assert data["build_pid"] == 4321
|
|
start_worker.assert_called_once()
|
|
finally:
|
|
app.dependency_overrides.pop(get_media_index, None)
|
|
|
|
def test_stop_requests_cancel(self, test_client, tmp_path):
|
|
index = MediaIndex(tmp_path / "index.sqlite")
|
|
index.init_schema()
|
|
index.set_metadata("build_running", "true")
|
|
index.set_metadata("build_stage", "building")
|
|
index.set_metadata("build_pid", "4321")
|
|
app.dependency_overrides[get_media_index] = lambda: index
|
|
|
|
with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True):
|
|
try:
|
|
response = test_client.post("/api/media/stop")
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "stop_requested"
|
|
assert data["build_cancel_requested"] is True
|
|
status = test_client.get("/api/media/status").json()
|
|
assert status["build_cancel_requested"] is True
|
|
assert status["build_stage"] == "canceling"
|
|
finally:
|
|
app.dependency_overrides.pop(get_media_index, None)
|
|
|
|
def test_force_stop_terminates_worker(self, test_client, tmp_path):
|
|
index = MediaIndex(tmp_path / "index.sqlite")
|
|
index.init_schema()
|
|
index.set_metadata("build_running", "true")
|
|
index.set_metadata("build_stage", "building")
|
|
index.set_metadata("build_pid", "4321")
|
|
app.dependency_overrides[get_media_index] = lambda: index
|
|
|
|
alive_calls = {"count": 0}
|
|
|
|
def fake_pid_is_alive(pid):
|
|
alive_calls["count"] += 1
|
|
return alive_calls["count"] <= 2
|
|
|
|
with (
|
|
patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive),
|
|
patch("media_library_viewer_api.routers.media.os.killpg") as killpg,
|
|
patch("media_library_viewer_api.routers.media.time.sleep", return_value=None),
|
|
):
|
|
try:
|
|
response = test_client.post("/api/media/force-stop")
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "force_stopped"
|
|
killpg.assert_called()
|
|
status = test_client.get("/api/media/status").json()
|
|
assert status["build_running"] is False
|
|
assert status["build_stage"] == "force-stopped"
|
|
finally:
|
|
app.dependency_overrides.pop(get_media_index, None)
|
|
|
|
def test_force_stop_returns_conflict_when_idle(self, test_client, tmp_path):
|
|
index = MediaIndex(tmp_path / "index.sqlite")
|
|
index.init_schema()
|
|
app.dependency_overrides[get_media_index] = lambda: index
|
|
|
|
try:
|
|
response = test_client.post("/api/media/force-stop")
|
|
assert response.status_code == 409
|
|
finally:
|
|
app.dependency_overrides.pop(get_media_index, None)
|
|
|
|
|
|
# --- Jobs ---
|
|
|
|
|
|
class TestJobs:
|
|
def test_list_templates(self, test_client):
|
|
response = test_client.get("/api/jobs/templates")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) >= 3
|
|
keys = [t["key"] for t in data]
|
|
assert "disk_usage" in keys
|
|
assert "ffprobe" in keys
|
|
|
|
def test_run_job(self, test_client, mock_ssh):
|
|
mock_ssh.run.return_value = CommandResult(
|
|
command="du -sh '/media/test'",
|
|
exit_status=0,
|
|
stdout="5.0G\t/media/test\n",
|
|
stderr="",
|
|
)
|
|
response = test_client.post("/api/jobs/run", json={"job_key": "disk_usage", "path": "/media/test"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["exit_status"] == 0
|
|
assert "5.0G" in data["stdout"]
|
|
|
|
def test_run_unknown_job(self, test_client):
|
|
response = test_client.post("/api/jobs/run", json={"job_key": "nonexistent", "path": "/x"})
|
|
assert response.status_code == 400
|
|
|
|
|
|
# --- Monitoring ---
|
|
|
|
|
|
class TestMonitoring:
|
|
def test_prometheus_targets_empty(self, test_client):
|
|
response = test_client.get("/api/monitoring/prometheus-targets")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
def test_prometheus_targets_returns_enabled_ssh_node_exporter(self, test_client):
|
|
store = app.dependency_overrides[get_settings_store]()
|
|
store.upsert_machine(
|
|
{
|
|
"name": "remote1",
|
|
"mode": "ssh",
|
|
"enabled": True,
|
|
"services": ["monitoring"],
|
|
"host": "10.0.0.5",
|
|
"username": "u",
|
|
"node_exporter_enabled": True,
|
|
"node_exporter_port": 9200,
|
|
"node_exporter_scrape_host": "1.2.3.4",
|
|
}
|
|
)
|
|
response = test_client.get("/api/monitoring/prometheus-targets")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["targets"] == ["1.2.3.4:9200"]
|
|
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
|
|
|
|
|
class TestResolveServiceRecord:
|
|
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
|
|
|
def _store(self, rows):
|
|
store = MagicMock()
|
|
store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None)
|
|
|
|
def list_filtered(service_type=None):
|
|
return [r for r in rows if r["service_type"] == (service_type or r["service_type"])]
|
|
|
|
store.list_services = list_filtered
|
|
return store
|
|
|
|
def test_service_id_match_returns_record(self):
|
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
|
|
|
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
|
store = self._store([row])
|
|
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
|
result = resolve_service_record(store, "alertmanager", "am1")
|
|
assert result == "RECORD"
|
|
mock_build.assert_called_once_with(store, row)
|
|
|
|
def test_service_id_type_mismatch_returns_none(self):
|
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
|
|
|
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
|
store = self._store([row])
|
|
assert resolve_service_record(store, "alertmanager", "x1") is None
|
|
|
|
def test_service_id_disabled_returns_none(self):
|
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
|
|
|
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
|
store = self._store([row])
|
|
assert resolve_service_record(store, "alertmanager", "am1") is None
|
|
|
|
def test_no_service_id_returns_first_enabled(self):
|
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
|
|
|
rows = [
|
|
{
|
|
"id": "am1",
|
|
"service_type": "alertmanager",
|
|
"name": "Disabled",
|
|
"enabled": False,
|
|
"config": {},
|
|
"secrets": {},
|
|
},
|
|
{
|
|
"id": "am2",
|
|
"service_type": "alertmanager",
|
|
"name": "Active",
|
|
"enabled": True,
|
|
"config": {},
|
|
"secrets": {},
|
|
},
|
|
]
|
|
store = self._store(rows)
|
|
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
|
result = resolve_service_record(store, "alertmanager", None)
|
|
assert result == "RECORD"
|
|
mock_build.assert_called_once_with(store, rows[1])
|
|
|
|
def test_no_service_id_and_none_enabled_returns_none(self):
|
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
|
|
|
store = self._store([])
|
|
assert resolve_service_record(store, "alertmanager", None) is None
|
|
|
|
|
|
class TestSettingsMachines:
|
|
def test_machine_appears_in_prometheus_targets(self, test_client):
|
|
store = app.dependency_overrides[get_settings_store]()
|
|
store.upsert_machine(
|
|
{
|
|
"name": "remote1",
|
|
"mode": "ssh",
|
|
"enabled": True,
|
|
"services": ["monitoring"],
|
|
"host": "10.0.0.5",
|
|
"username": "u",
|
|
"node_exporter_enabled": True,
|
|
"node_exporter_port": 9200,
|
|
"node_exporter_scrape_host": "1.2.3.4",
|
|
}
|
|
)
|
|
targets = test_client.get("/api/monitoring/prometheus-targets").json()
|
|
assert len(targets) == 1
|
|
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
|
|
|
def test_delete_machine_removed_from_prometheus_targets(self, test_client):
|
|
store = app.dependency_overrides[get_settings_store]()
|
|
machine = store.upsert_machine(
|
|
{
|
|
"name": "remote1",
|
|
"mode": "ssh",
|
|
"enabled": True,
|
|
"services": ["monitoring"],
|
|
"host": "10.0.0.5",
|
|
"username": "u",
|
|
"node_exporter_enabled": True,
|
|
"node_exporter_port": 9200,
|
|
"node_exporter_scrape_host": "1.2.3.4",
|
|
}
|
|
)
|
|
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
|
assert response.status_code == 200
|
|
assert test_client.get("/api/monitoring/prometheus-targets").json() == []
|
|
|
|
|
|
def _am_service(name="Alertmanager", **config):
|
|
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
|
|
cfg.update(config)
|
|
return ServiceRecord(id="am1", service_type="alertmanager", name=name, config=cfg)
|
|
|
|
|
|
class TestAlertmanager:
|
|
def test_alerts_endpoint_when_not_configured(self, test_client):
|
|
# No alertmanager service seeded -> registry resolves None.
|
|
response = test_client.get("/api/monitoring/alerts")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 0
|
|
assert data["error"] == "alertmanager_not_configured"
|
|
|
|
def test_alerts_endpoint_when_unreachable(self, test_client):
|
|
service = _am_service()
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
|
):
|
|
response = test_client.get("/api/monitoring/alerts")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 0
|
|
assert data["error"] == "alertmanager_unreachable"
|
|
assert data["service_id"] == "am1"
|
|
assert data["name"] == "Alertmanager"
|
|
|
|
def test_alerts_endpoint_returns_summary(self, test_client):
|
|
service = _am_service()
|
|
resp = MagicMock()
|
|
resp.json.return_value = {
|
|
"status": "success",
|
|
"data": [
|
|
{
|
|
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
|
"annotations": {"summary": "Backup failed", "description": "details"},
|
|
"startsAt": "2026-05-11T02:00:00Z",
|
|
"status": "firing",
|
|
}
|
|
],
|
|
}
|
|
resp.raise_for_status = MagicMock()
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", return_value=resp),
|
|
):
|
|
response = test_client.get("/api/monitoring/alerts")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 1
|
|
assert data["by_severity"]["critical"] == 1
|
|
assert data["alerts"][0]["name"] == "BackupJobFailed"
|
|
assert data["service_id"] == "am1"
|
|
|
|
def test_alerts_endpoint_sends_bearer_token(self, test_client):
|
|
service = _am_service()
|
|
service.secrets = {"api_key": "tok"}
|
|
resp = MagicMock()
|
|
resp.json.return_value = {"status": "success", "data": []}
|
|
resp.raise_for_status = MagicMock()
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
|
):
|
|
test_client.get("/api/monitoring/alerts")
|
|
_, kwargs = mock_get.call_args
|
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
|
|
|
def test_alertmanager_status_when_not_configured(self, test_client):
|
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is False
|
|
assert data["error"] == "alertmanager_not_configured"
|
|
assert data["peers"] == []
|
|
|
|
def test_alertmanager_status_when_unreachable(self, test_client):
|
|
service = _am_service()
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
|
):
|
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is False
|
|
assert data["error"] == "alertmanager_unreachable"
|
|
assert data["name"] == "Alertmanager"
|
|
|
|
def test_alertmanager_status_returns_cluster_info(self, test_client):
|
|
service = _am_service()
|
|
resp = MagicMock()
|
|
resp.json.return_value = {
|
|
"versionInfo": {"version": "0.27.0"},
|
|
"clusterStatus": {"createdAt": "2026-06-01T00:00:00Z"},
|
|
"cluster": {"peers": [{"name": "am-1"}, {"name": "am-2"}]},
|
|
}
|
|
resp.raise_for_status = MagicMock()
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", return_value=resp),
|
|
):
|
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is True
|
|
assert data["version"] == "0.27.0"
|
|
assert data["peers"] == ["am-1", "am-2"]
|
|
|
|
|
|
class TestAlertmanagerWebhook:
|
|
"""Tests for POST /api/monitoring/alertmanager-webhook (log-only receiver)."""
|
|
|
|
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
|
|
payload = {
|
|
"status": "firing",
|
|
"alerts": [
|
|
{
|
|
"status": "firing",
|
|
"labels": {"alertname": "BackupJobFailed", "severity": "critical"},
|
|
"annotations": {"summary": "Backup failed"},
|
|
"startsAt": "2026-06-13T10:00:00Z",
|
|
}
|
|
],
|
|
}
|
|
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
|
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "received"
|
|
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
|
|
|
|
|
class TestGrafanaStatus:
|
|
def test_grafana_status_when_not_configured(self, test_client):
|
|
response = test_client.get("/api/monitoring/grafana-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is False
|
|
assert data["error"] == "no_service_configured"
|
|
|
|
def test_grafana_status_when_unreachable(self, test_client):
|
|
service = ServiceRecord(
|
|
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
|
)
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
|
):
|
|
response = test_client.get("/api/monitoring/grafana-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is False
|
|
assert data["error"] == "grafana_unreachable"
|
|
assert data["name"] == "Grafana"
|
|
|
|
def test_grafana_status_returns_version(self, test_client):
|
|
service = ServiceRecord(
|
|
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
|
)
|
|
resp = MagicMock()
|
|
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
|
resp.raise_for_status = MagicMock()
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", return_value=resp),
|
|
):
|
|
response = test_client.get("/api/monitoring/grafana-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is True
|
|
assert data["version"] == "11.3.1"
|
|
assert data["service_id"] == "g1"
|
|
|
|
|
|
class TestPrometheusStatus:
|
|
def test_prometheus_status_when_not_configured(self, test_client):
|
|
response = test_client.get("/api/monitoring/prometheus-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is False
|
|
assert data["error"] == "no_service_configured"
|
|
|
|
def test_prometheus_status_when_unreachable(self, test_client):
|
|
service = ServiceRecord(
|
|
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
|
)
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
|
):
|
|
response = test_client.get("/api/monitoring/prometheus-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is False
|
|
assert data["error"] == "prometheus_unreachable"
|
|
|
|
def test_prometheus_status_returns_version(self, test_client):
|
|
service = ServiceRecord(
|
|
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
|
)
|
|
health = MagicMock()
|
|
health.raise_for_status = MagicMock()
|
|
build_info = MagicMock()
|
|
build_info.raise_for_status = MagicMock()
|
|
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
|
with (
|
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
|
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
|
):
|
|
response = test_client.get("/api/monitoring/prometheus-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["up"] is True
|
|
assert data["version"] == "2.55.1"
|
|
assert data["service_id"] == "p1"
|