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:
@@ -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
|
||||
Reference in New Issue
Block a user