Add comprehensive test suite for backend (133 tests)

Test coverage:
- test_utils.py: formatting helpers (human_size, ticks_to_minutes,
  format_duration, format_bitrate, ffprobe summaries, stream parsing)
- test_path_utils.py: path resolution (prefix, media root mapping,
  edge cases with spaces/special chars)
- test_domain_media.py: Jellyfin item normalization (HDR detection,
  media sources, stream extraction, display formatting)
- test_jobs.py: job template rendering and shell quoting safety
- test_media_index.py: SQLite index CRUD, querying, filtering,
  sorting, pagination
- test_config.py: pydantic-settings env loading
- test_api.py: full FastAPI integration tests with mocked SSH/Jellyfin
  (all endpoints: dashboard, monitoring, media, files, jobs)

All tests run without network/SSH dependencies using mocks.
This commit is contained in:
2026-05-03 12:45:14 +02:00
parent 51b10438a9
commit 47baee854b
10 changed files with 1199 additions and 45 deletions
+248
View File
@@ -0,0 +1,248 @@
"""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 unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from media_library_viewer_api.main import app
from media_library_viewer_api.dependencies import get_ssh_client, get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.ssh import CommandResult
# --- 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.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.active_sessions.return_value = [
{
"Id": "sess1",
"UserName": "alex",
"DeviceName": "Chrome",
"NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"},
"PlayState": {"IsPaused": False},
"TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False},
}
]
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):
"""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"
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"}
# --- 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_now_playing(self, test_client):
response = test_client.get("/api/dashboard/now-playing")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["user"] == "alex"
assert data[0]["title"] == "Test Movie"
assert data[0]["transcoding"] == "yes"
assert "audio" in data[0]["transcoding_type"]
# --- 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
# --- 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_status(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="...", exit_status=0, stdout="running pid=1234\n", stderr=""
)
response = test_client.get("/api/monitoring/status")
assert response.status_code == 200
assert "running" in response.json()["status"]
def test_metrics_empty(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="...", exit_status=0, stdout="", stderr=""
)
response = test_client.get("/api/monitoring/metrics")
assert response.status_code == 200
data = response.json()
assert data["samples"] == []
def test_disk(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="df ...",
exit_status=0,
stdout='{"filesystem":"/dev/sda1","size":1000000000,"used":500000000,"available":500000000,"used_pct":"50%","mount":"/"}',
stderr="",
)
response = test_client.get("/api/monitoring/disk")
assert response.status_code == 200
data = response.json()
assert data["used_pct"] == "50%"
def test_start(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="...", exit_status=0, stdout="started pid=5678\n", stderr=""
)
response = test_client.post("/api/monitoring/start")
assert response.status_code == 200
assert "started" in response.json()["message"]
def test_stop(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="...", exit_status=0, stdout="stopped pid=5678\n", stderr=""
)
response = test_client.post("/api/monitoring/stop")
assert response.status_code == 200
def test_restart(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="...", exit_status=0, stdout="stopped pid=5678\nstarted pid=9999\n", stderr=""
)
response = test_client.post("/api/monitoring/restart")
assert response.status_code == 200