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:
@@ -1,63 +1,66 @@
|
||||
"""Backend configuration using pydantic-settings.
|
||||
|
||||
Reads from environment variables and .env file automatically.
|
||||
Uses a flat settings model so that standard env vars like SSH_HOST,
|
||||
JELLYFIN_URL etc. are picked up directly without nested-model complications.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class JellyfinSettings(BaseSettings):
|
||||
url: str = ""
|
||||
api_key: str = ""
|
||||
user_id: str = ""
|
||||
|
||||
model_config = {"env_prefix": "JELLYFIN_"}
|
||||
|
||||
|
||||
class SSHSettings(BaseSettings):
|
||||
host: str = ""
|
||||
username: str = ""
|
||||
port: int = 22
|
||||
key_filename: str = str(Path.home() / ".ssh" / "id_rsa")
|
||||
password: str = ""
|
||||
media_root: str = ""
|
||||
path_prefix: str = ""
|
||||
|
||||
model_config = {"env_prefix": "SSH_"}
|
||||
|
||||
|
||||
class RemoteSettings(BaseSettings):
|
||||
"""Extra remote settings that don't fit the SSH_ prefix."""
|
||||
|
||||
media_root: str = ""
|
||||
path_prefix: str = ""
|
||||
|
||||
model_config = {"env_prefix": "REMOTE_"}
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Top-level application settings."""
|
||||
"""Flat application settings read from env vars / .env file."""
|
||||
|
||||
jellyfin: JellyfinSettings = JellyfinSettings()
|
||||
ssh: SSHSettings = SSHSettings()
|
||||
remote: RemoteSettings = RemoteSettings()
|
||||
# Jellyfin
|
||||
jellyfin_url: str = ""
|
||||
jellyfin_api_key: str = ""
|
||||
jellyfin_user_id: str = ""
|
||||
|
||||
# Derived convenience properties
|
||||
# SSH
|
||||
ssh_host: str = ""
|
||||
ssh_username: str = ""
|
||||
ssh_port: int = 22
|
||||
ssh_key_filename: str = str(Path.home() / ".ssh" / "id_rsa")
|
||||
ssh_password: str = ""
|
||||
|
||||
# Remote paths
|
||||
remote_media_root: str = ""
|
||||
remote_path_prefix: str = ""
|
||||
|
||||
# Derived convenience
|
||||
@property
|
||||
def media_root(self) -> str:
|
||||
return self.remote.media_root or self.ssh.media_root or ""
|
||||
return self.remote_media_root or ""
|
||||
|
||||
@property
|
||||
def path_prefix(self) -> str:
|
||||
return self.remote.path_prefix or self.ssh.path_prefix or ""
|
||||
return self.remote_path_prefix or ""
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
|
||||
|
||||
def _find_env_file() -> str | None:
|
||||
"""Look for .env in CWD, then parent directories up to the repo root."""
|
||||
current = Path.cwd()
|
||||
for directory in [current, *current.parents]:
|
||||
candidate = directory / ".env"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
# Stop at repo root (has .git)
|
||||
if (directory / ".git").exists():
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Create a Settings instance (reads env/.env on each call)."""
|
||||
"""Return a cached Settings instance."""
|
||||
env_file = _find_env_file()
|
||||
if env_file:
|
||||
return Settings(_env_file=env_file)
|
||||
return Settings()
|
||||
|
||||
@@ -17,7 +17,7 @@ from media_library_viewer_api.config import get_settings
|
||||
def get_jellyfin_client() -> JellyfinClient:
|
||||
"""Return a cached Jellyfin client."""
|
||||
settings = get_settings()
|
||||
return JellyfinClient(settings.jellyfin.url, settings.jellyfin.api_key)
|
||||
return JellyfinClient(settings.jellyfin_url, settings.jellyfin_api_key)
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -25,11 +25,11 @@ def get_ssh_client() -> RemoteSSHClient:
|
||||
"""Return a cached SSH client (connects on first use)."""
|
||||
settings = get_settings()
|
||||
client = RemoteSSHClient(
|
||||
host=settings.ssh.host,
|
||||
username=settings.ssh.username,
|
||||
port=settings.ssh.port,
|
||||
key_filename=settings.ssh.key_filename or None,
|
||||
password=settings.ssh.password or None,
|
||||
host=settings.ssh_host,
|
||||
username=settings.ssh_username,
|
||||
port=settings.ssh_port,
|
||||
key_filename=settings.ssh_key_filename or None,
|
||||
password=settings.ssh_password or None,
|
||||
)
|
||||
client.connect()
|
||||
return client
|
||||
@@ -38,8 +38,8 @@ def get_ssh_client() -> RemoteSSHClient:
|
||||
def get_user_id() -> str:
|
||||
"""Return the configured Jellyfin user ID, or discover the first available user."""
|
||||
settings = get_settings()
|
||||
if settings.jellyfin.user_id:
|
||||
return settings.jellyfin.user_id
|
||||
if settings.jellyfin_user_id:
|
||||
return settings.jellyfin_user_id
|
||||
client = get_jellyfin_client()
|
||||
users = client.users()
|
||||
if not users:
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -49,3 +50,7 @@ app.include_router(jobs.router)
|
||||
def health_check() -> dict[str, str]:
|
||||
"""Simple health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
Reference in New Issue
Block a user