Task 4: Add Services & Utilities tests (66 tests)
- test_media_utils.py: 29 tests for check_media_files, get_media_file_paths, has_all_images, count_video_files, has_video_files, constants - test_nfo_factory.py: 11 tests for NFOServiceFactory.create, create_optional, get_nfo_factory singleton, create_nfo_service convenience - test_series_manager_service.py: 15 tests for init, from_settings, process_nfo_for_series, scan_and_process_nfo, close - test_templates_utils.py: 4 tests for TEMPLATES_DIR path resolution - test_error_controller.py: 7 tests for 404/500 handlers (API vs HTML)
This commit is contained in:
110
tests/unit/test_error_controller.py
Normal file
110
tests/unit/test_error_controller.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for error controller module.
|
||||||
|
|
||||||
|
Tests custom 404 and 500 error handlers for both API and HTML responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from src.server.controllers.error_controller import (
|
||||||
|
not_found_handler,
|
||||||
|
server_error_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(path: str = "/") -> MagicMock:
|
||||||
|
"""Create a mock Request object with a given path."""
|
||||||
|
request = MagicMock(spec=Request)
|
||||||
|
url = MagicMock()
|
||||||
|
url.path = path
|
||||||
|
request.url = url
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotFoundHandler:
|
||||||
|
"""Tests for the 404 not_found_handler."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_path_returns_json(self):
|
||||||
|
"""API paths get a JSON response with 404 status."""
|
||||||
|
request = _make_request("/api/anime/123")
|
||||||
|
exc = HTTPException(status_code=404)
|
||||||
|
resp = await not_found_handler(request, exc)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.body is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("src.server.controllers.error_controller.render_template")
|
||||||
|
async def test_web_path_renders_template(self, mock_render):
|
||||||
|
"""Non-API paths render the error template."""
|
||||||
|
mock_render.return_value = MagicMock(status_code=404)
|
||||||
|
request = _make_request("/anime/details")
|
||||||
|
exc = HTTPException(status_code=404)
|
||||||
|
await not_found_handler(request, exc)
|
||||||
|
mock_render.assert_called_once_with(
|
||||||
|
"error.html",
|
||||||
|
request,
|
||||||
|
context={"error": "Page not found", "status_code": 404},
|
||||||
|
title="404 - Not Found",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_json_structure(self):
|
||||||
|
"""API 404 response has 'detail' field."""
|
||||||
|
import json
|
||||||
|
request = _make_request("/api/missing")
|
||||||
|
exc = HTTPException(status_code=404)
|
||||||
|
resp = await not_found_handler(request, exc)
|
||||||
|
body = json.loads(resp.body)
|
||||||
|
assert body["detail"] == "API endpoint not found"
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerErrorHandler:
|
||||||
|
"""Tests for the 500 server_error_handler."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_path_returns_json(self):
|
||||||
|
"""API paths get a JSON response with 500 status."""
|
||||||
|
request = _make_request("/api/download")
|
||||||
|
exc = RuntimeError("crash")
|
||||||
|
resp = await server_error_handler(request, exc)
|
||||||
|
assert resp.status_code == 500
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("src.server.controllers.error_controller.render_template")
|
||||||
|
async def test_web_path_renders_template(self, mock_render):
|
||||||
|
"""Non-API paths render the error template."""
|
||||||
|
mock_render.return_value = MagicMock(status_code=500)
|
||||||
|
request = _make_request("/settings")
|
||||||
|
exc = RuntimeError("crash")
|
||||||
|
await server_error_handler(request, exc)
|
||||||
|
mock_render.assert_called_once_with(
|
||||||
|
"error.html",
|
||||||
|
request,
|
||||||
|
context={"error": "Internal server error", "status_code": 500},
|
||||||
|
title="500 - Server Error",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_error_does_not_expose_stack_trace(self):
|
||||||
|
"""API 500 response doesn't contain the actual error message."""
|
||||||
|
import json
|
||||||
|
request = _make_request("/api/vulnerable")
|
||||||
|
exc = RuntimeError("secret database credentials")
|
||||||
|
resp = await server_error_handler(request, exc)
|
||||||
|
body = json.loads(resp.body)
|
||||||
|
assert "secret" not in body["detail"]
|
||||||
|
assert body["detail"] == "Internal server error"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_path_detection(self):
|
||||||
|
"""Correctly distinguishes API vs web paths."""
|
||||||
|
api_request = _make_request("/api/test")
|
||||||
|
web_request = _make_request("/dashboard")
|
||||||
|
|
||||||
|
# API path returns JSONResponse
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
resp = await server_error_handler(api_request, Exception("err"))
|
||||||
|
assert isinstance(resp, JSONResponse)
|
||||||
236
tests/unit/test_media_utils.py
Normal file
236
tests/unit/test_media_utils.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Unit tests for media file utilities.
|
||||||
|
|
||||||
|
Tests media file checking, path resolution, video file counting,
|
||||||
|
and media file existence validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.server.utils.media import (
|
||||||
|
FANART_FILENAME,
|
||||||
|
LOGO_FILENAME,
|
||||||
|
NFO_FILENAME,
|
||||||
|
POSTER_FILENAME,
|
||||||
|
VIDEO_EXTENSIONS,
|
||||||
|
check_media_files,
|
||||||
|
count_video_files,
|
||||||
|
get_media_file_paths,
|
||||||
|
has_all_images,
|
||||||
|
has_video_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckMediaFiles:
|
||||||
|
"""Tests for check_media_files function."""
|
||||||
|
|
||||||
|
def test_all_files_exist(self, tmp_path: Path):
|
||||||
|
"""Returns True for all file types when they exist."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
(tmp_path / LOGO_FILENAME).touch()
|
||||||
|
(tmp_path / FANART_FILENAME).touch()
|
||||||
|
(tmp_path / NFO_FILENAME).touch()
|
||||||
|
result = check_media_files(tmp_path)
|
||||||
|
assert result == {
|
||||||
|
"poster": True, "logo": True,
|
||||||
|
"fanart": True, "nfo": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_no_files_exist(self, tmp_path: Path):
|
||||||
|
"""Returns False for all when no media files exist."""
|
||||||
|
result = check_media_files(tmp_path)
|
||||||
|
assert all(v is False for v in result.values())
|
||||||
|
|
||||||
|
def test_partial_files(self, tmp_path: Path):
|
||||||
|
"""Returns mixed results when some files exist."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
result = check_media_files(tmp_path)
|
||||||
|
assert result["poster"] is True
|
||||||
|
assert result["logo"] is False
|
||||||
|
|
||||||
|
def test_accepts_string_path(self, tmp_path: Path):
|
||||||
|
"""Works with string path arguments."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
result = check_media_files(str(tmp_path))
|
||||||
|
assert result["poster"] is True
|
||||||
|
|
||||||
|
def test_selective_checks(self, tmp_path: Path):
|
||||||
|
"""Only checks requested file types."""
|
||||||
|
result = check_media_files(
|
||||||
|
tmp_path, check_poster=True, check_logo=False,
|
||||||
|
check_fanart=False, check_nfo=False,
|
||||||
|
)
|
||||||
|
assert "poster" in result
|
||||||
|
assert "logo" not in result
|
||||||
|
assert "fanart" not in result
|
||||||
|
assert "nfo" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMediaFilePaths:
|
||||||
|
"""Tests for get_media_file_paths function."""
|
||||||
|
|
||||||
|
def test_returns_paths_for_existing_files(self, tmp_path: Path):
|
||||||
|
"""Returns Path objects for files that exist."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
result = get_media_file_paths(tmp_path)
|
||||||
|
assert result["poster"] == tmp_path / POSTER_FILENAME
|
||||||
|
|
||||||
|
def test_returns_none_for_missing_files(self, tmp_path: Path):
|
||||||
|
"""Returns None for files that don't exist."""
|
||||||
|
result = get_media_file_paths(tmp_path)
|
||||||
|
assert result["poster"] is None
|
||||||
|
assert result["logo"] is None
|
||||||
|
|
||||||
|
def test_selective_includes(self, tmp_path: Path):
|
||||||
|
"""Only includes requested file types."""
|
||||||
|
result = get_media_file_paths(
|
||||||
|
tmp_path, include_poster=True, include_logo=False,
|
||||||
|
include_fanart=False, include_nfo=False,
|
||||||
|
)
|
||||||
|
assert "poster" in result
|
||||||
|
assert "logo" not in result
|
||||||
|
|
||||||
|
def test_accepts_string_path(self, tmp_path: Path):
|
||||||
|
"""Works with string path arguments."""
|
||||||
|
(tmp_path / NFO_FILENAME).touch()
|
||||||
|
result = get_media_file_paths(str(tmp_path))
|
||||||
|
assert result["nfo"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasAllImages:
|
||||||
|
"""Tests for has_all_images function."""
|
||||||
|
|
||||||
|
def test_true_when_all_images_present(self, tmp_path: Path):
|
||||||
|
"""Returns True when poster, logo, and fanart all exist."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
(tmp_path / LOGO_FILENAME).touch()
|
||||||
|
(tmp_path / FANART_FILENAME).touch()
|
||||||
|
assert has_all_images(tmp_path) is True
|
||||||
|
|
||||||
|
def test_false_when_missing_one(self, tmp_path: Path):
|
||||||
|
"""Returns False when any image is missing."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
(tmp_path / LOGO_FILENAME).touch()
|
||||||
|
# fanart missing
|
||||||
|
assert has_all_images(tmp_path) is False
|
||||||
|
|
||||||
|
def test_false_when_no_images(self, tmp_path: Path):
|
||||||
|
"""Returns False when no images exist."""
|
||||||
|
assert has_all_images(tmp_path) is False
|
||||||
|
|
||||||
|
def test_nfo_not_required(self, tmp_path: Path):
|
||||||
|
"""NFO file presence doesn't affect image check."""
|
||||||
|
(tmp_path / POSTER_FILENAME).touch()
|
||||||
|
(tmp_path / LOGO_FILENAME).touch()
|
||||||
|
(tmp_path / FANART_FILENAME).touch()
|
||||||
|
# NFO is not an image - shouldn't matter
|
||||||
|
assert has_all_images(tmp_path) is True
|
||||||
|
|
||||||
|
def test_accepts_string_path(self, tmp_path: Path):
|
||||||
|
"""Works with string path arguments."""
|
||||||
|
assert has_all_images(str(tmp_path)) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCountVideoFiles:
|
||||||
|
"""Tests for count_video_files function."""
|
||||||
|
|
||||||
|
def test_counts_mp4_files(self, tmp_path: Path):
|
||||||
|
"""Counts .mp4 video files."""
|
||||||
|
(tmp_path / "ep01.mp4").touch()
|
||||||
|
(tmp_path / "ep02.mp4").touch()
|
||||||
|
assert count_video_files(tmp_path) == 2
|
||||||
|
|
||||||
|
def test_counts_multiple_extensions(self, tmp_path: Path):
|
||||||
|
"""Counts video files with various extensions."""
|
||||||
|
(tmp_path / "ep01.mp4").touch()
|
||||||
|
(tmp_path / "ep02.mkv").touch()
|
||||||
|
(tmp_path / "ep03.avi").touch()
|
||||||
|
assert count_video_files(tmp_path) == 3
|
||||||
|
|
||||||
|
def test_ignores_non_video_files(self, tmp_path: Path):
|
||||||
|
"""Non-video files are not counted."""
|
||||||
|
(tmp_path / "ep01.mp4").touch()
|
||||||
|
(tmp_path / "poster.jpg").touch()
|
||||||
|
(tmp_path / "readme.txt").touch()
|
||||||
|
assert count_video_files(tmp_path) == 1
|
||||||
|
|
||||||
|
def test_recursive_counts_subdirectories(self, tmp_path: Path):
|
||||||
|
"""Recursive mode counts videos in subdirectories."""
|
||||||
|
s1 = tmp_path / "Season 1"
|
||||||
|
s1.mkdir()
|
||||||
|
(s1 / "ep01.mp4").touch()
|
||||||
|
(s1 / "ep02.mp4").touch()
|
||||||
|
s2 = tmp_path / "Season 2"
|
||||||
|
s2.mkdir()
|
||||||
|
(s2 / "ep01.mkv").touch()
|
||||||
|
assert count_video_files(tmp_path, recursive=True) == 3
|
||||||
|
|
||||||
|
def test_non_recursive_skips_subdirectories(self, tmp_path: Path):
|
||||||
|
"""Non-recursive mode only counts root-level files."""
|
||||||
|
(tmp_path / "ep01.mp4").touch()
|
||||||
|
sub = tmp_path / "Season 1"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "ep02.mp4").touch()
|
||||||
|
assert count_video_files(tmp_path, recursive=False) == 1
|
||||||
|
|
||||||
|
def test_nonexistent_folder_returns_zero(self):
|
||||||
|
"""Returns 0 for nonexistent folder."""
|
||||||
|
assert count_video_files("/nonexistent/path") == 0
|
||||||
|
|
||||||
|
def test_empty_folder_returns_zero(self, tmp_path: Path):
|
||||||
|
"""Returns 0 for empty folder."""
|
||||||
|
assert count_video_files(tmp_path) == 0
|
||||||
|
|
||||||
|
def test_case_insensitive_extensions(self, tmp_path: Path):
|
||||||
|
"""Video extensions are matched case-insensitively."""
|
||||||
|
(tmp_path / "ep01.MP4").touch()
|
||||||
|
(tmp_path / "ep02.Mkv").touch()
|
||||||
|
assert count_video_files(tmp_path) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasVideoFiles:
|
||||||
|
"""Tests for has_video_files function."""
|
||||||
|
|
||||||
|
def test_true_when_videos_exist(self, tmp_path: Path):
|
||||||
|
"""Returns True when video files are present."""
|
||||||
|
(tmp_path / "ep01.mp4").touch()
|
||||||
|
assert has_video_files(tmp_path) is True
|
||||||
|
|
||||||
|
def test_false_when_no_videos(self, tmp_path: Path):
|
||||||
|
"""Returns False when no video files exist."""
|
||||||
|
(tmp_path / "readme.txt").touch()
|
||||||
|
assert has_video_files(tmp_path) is False
|
||||||
|
|
||||||
|
def test_false_for_nonexistent_folder(self):
|
||||||
|
"""Returns False for nonexistent folder."""
|
||||||
|
assert has_video_files("/nonexistent/path") is False
|
||||||
|
|
||||||
|
def test_finds_videos_in_subdirectories(self, tmp_path: Path):
|
||||||
|
"""Detects videos in nested subdirectories."""
|
||||||
|
sub = tmp_path / "Season 1"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "ep01.mkv").touch()
|
||||||
|
assert has_video_files(tmp_path) is True
|
||||||
|
|
||||||
|
def test_all_supported_extensions(self, tmp_path: Path):
|
||||||
|
"""All supported video extensions are recognized."""
|
||||||
|
for ext in VIDEO_EXTENSIONS:
|
||||||
|
(tmp_path / f"test{ext}").touch()
|
||||||
|
assert has_video_files(tmp_path) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
"""Tests for module constants."""
|
||||||
|
|
||||||
|
def test_standard_filenames(self):
|
||||||
|
"""Standard media filenames match Kodi/Plex conventions."""
|
||||||
|
assert POSTER_FILENAME == "poster.jpg"
|
||||||
|
assert LOGO_FILENAME == "logo.png"
|
||||||
|
assert FANART_FILENAME == "fanart.jpg"
|
||||||
|
assert NFO_FILENAME == "tvshow.nfo"
|
||||||
|
|
||||||
|
def test_video_extensions_includes_common_formats(self):
|
||||||
|
"""VIDEO_EXTENSIONS includes all common video formats."""
|
||||||
|
for ext in [".mp4", ".mkv", ".avi", ".webm"]:
|
||||||
|
assert ext in VIDEO_EXTENSIONS
|
||||||
189
tests/unit/test_nfo_factory.py
Normal file
189
tests/unit/test_nfo_factory.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""Unit tests for NFO service factory module.
|
||||||
|
|
||||||
|
Tests factory instantiation, configuration precedence, singleton pattern,
|
||||||
|
and convenience functions for creating NFOService instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.services.nfo_factory import (
|
||||||
|
NFOServiceFactory,
|
||||||
|
create_nfo_service,
|
||||||
|
get_nfo_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNFOServiceFactoryCreate:
|
||||||
|
"""Tests for NFOServiceFactory.create method."""
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.NFOService")
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_create_with_explicit_api_key(self, mock_settings, mock_nfo_cls):
|
||||||
|
"""Explicit API key takes priority over settings."""
|
||||||
|
mock_settings.tmdb_api_key = "settings_key"
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
mock_settings.nfo_image_size = "original"
|
||||||
|
mock_settings.nfo_auto_create = False
|
||||||
|
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
factory.create(tmdb_api_key="explicit_key")
|
||||||
|
mock_nfo_cls.assert_called_once_with(
|
||||||
|
tmdb_api_key="explicit_key",
|
||||||
|
anime_directory="/anime",
|
||||||
|
image_size="original",
|
||||||
|
auto_create=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.NFOService")
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_create_falls_back_to_settings(self, mock_settings, mock_nfo_cls):
|
||||||
|
"""Falls back to settings when no explicit key provided."""
|
||||||
|
mock_settings.tmdb_api_key = "settings_key"
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
mock_settings.nfo_image_size = "w500"
|
||||||
|
mock_settings.nfo_auto_create = True
|
||||||
|
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
factory.create()
|
||||||
|
mock_nfo_cls.assert_called_once_with(
|
||||||
|
tmdb_api_key="settings_key",
|
||||||
|
anime_directory="/anime",
|
||||||
|
image_size="w500",
|
||||||
|
auto_create=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_create_raises_without_api_key(self, mock_settings):
|
||||||
|
"""Raises ValueError when no API key available from any source."""
|
||||||
|
mock_settings.tmdb_api_key = None
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
factory._get_api_key_from_config = MagicMock(return_value=None)
|
||||||
|
with pytest.raises(ValueError, match="TMDB API key not configured"):
|
||||||
|
factory.create()
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.NFOService")
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_create_with_all_custom_params(self, mock_settings, mock_nfo_cls):
|
||||||
|
"""All parameters can be overridden."""
|
||||||
|
mock_settings.tmdb_api_key = "default"
|
||||||
|
mock_settings.anime_directory = "/default"
|
||||||
|
mock_settings.nfo_image_size = "original"
|
||||||
|
mock_settings.nfo_auto_create = False
|
||||||
|
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
factory.create(
|
||||||
|
tmdb_api_key="custom",
|
||||||
|
anime_directory="/custom",
|
||||||
|
image_size="w300",
|
||||||
|
auto_create=True,
|
||||||
|
)
|
||||||
|
mock_nfo_cls.assert_called_once_with(
|
||||||
|
tmdb_api_key="custom",
|
||||||
|
anime_directory="/custom",
|
||||||
|
image_size="w300",
|
||||||
|
auto_create=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.NFOService")
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_create_uses_config_json_fallback(self, mock_settings, mock_nfo_cls):
|
||||||
|
"""Falls back to config.json when settings has no key."""
|
||||||
|
mock_settings.tmdb_api_key = None
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
mock_settings.nfo_image_size = "original"
|
||||||
|
mock_settings.nfo_auto_create = False
|
||||||
|
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
factory._get_api_key_from_config = MagicMock(return_value="config_key")
|
||||||
|
factory.create()
|
||||||
|
mock_nfo_cls.assert_called_once()
|
||||||
|
call_kwargs = mock_nfo_cls.call_args[1]
|
||||||
|
assert call_kwargs["tmdb_api_key"] == "config_key"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNFOServiceFactoryCreateOptional:
|
||||||
|
"""Tests for NFOServiceFactory.create_optional method."""
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_returns_none_without_api_key(self, mock_settings):
|
||||||
|
"""Returns None instead of raising when no API key."""
|
||||||
|
mock_settings.tmdb_api_key = None
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
factory._get_api_key_from_config = MagicMock(return_value=None)
|
||||||
|
result = factory.create_optional()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.NFOService")
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_returns_service_when_configured(self, mock_settings, mock_nfo_cls):
|
||||||
|
"""Returns NFOService when configuration is available."""
|
||||||
|
mock_settings.tmdb_api_key = "key123"
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
mock_settings.nfo_image_size = "original"
|
||||||
|
mock_settings.nfo_auto_create = False
|
||||||
|
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
result = factory.create_optional()
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNfoFactory:
|
||||||
|
"""Tests for get_nfo_factory singleton function."""
|
||||||
|
|
||||||
|
def test_returns_factory_instance(self):
|
||||||
|
"""Returns an NFOServiceFactory instance."""
|
||||||
|
import src.core.services.nfo_factory as mod
|
||||||
|
old = mod._factory_instance
|
||||||
|
try:
|
||||||
|
mod._factory_instance = None
|
||||||
|
factory = get_nfo_factory()
|
||||||
|
assert isinstance(factory, NFOServiceFactory)
|
||||||
|
finally:
|
||||||
|
mod._factory_instance = old
|
||||||
|
|
||||||
|
def test_returns_same_instance(self):
|
||||||
|
"""Repeated calls return the same singleton."""
|
||||||
|
import src.core.services.nfo_factory as mod
|
||||||
|
old = mod._factory_instance
|
||||||
|
try:
|
||||||
|
mod._factory_instance = None
|
||||||
|
f1 = get_nfo_factory()
|
||||||
|
f2 = get_nfo_factory()
|
||||||
|
assert f1 is f2
|
||||||
|
finally:
|
||||||
|
mod._factory_instance = old
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateNfoService:
|
||||||
|
"""Tests for create_nfo_service convenience function."""
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.NFOService")
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_convenience_function_creates_service(
|
||||||
|
self, mock_settings, mock_nfo_cls
|
||||||
|
):
|
||||||
|
"""Convenience function delegates to factory.create()."""
|
||||||
|
mock_settings.tmdb_api_key = "key"
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
mock_settings.nfo_image_size = "original"
|
||||||
|
mock_settings.nfo_auto_create = False
|
||||||
|
|
||||||
|
result = create_nfo_service()
|
||||||
|
mock_nfo_cls.assert_called_once()
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.settings")
|
||||||
|
def test_convenience_function_raises_without_key(self, mock_settings):
|
||||||
|
"""Convenience function raises ValueError without key."""
|
||||||
|
mock_settings.tmdb_api_key = None
|
||||||
|
import src.core.services.nfo_factory as mod
|
||||||
|
old = mod._factory_instance
|
||||||
|
try:
|
||||||
|
mod._factory_instance = None
|
||||||
|
factory = get_nfo_factory()
|
||||||
|
factory._get_api_key_from_config = MagicMock(return_value=None)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
factory.create()
|
||||||
|
finally:
|
||||||
|
mod._factory_instance = old
|
||||||
209
tests/unit/test_series_manager_service.py
Normal file
209
tests/unit/test_series_manager_service.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Unit tests for series manager service.
|
||||||
|
|
||||||
|
Tests series orchestration, NFO processing, configuration handling,
|
||||||
|
and async batch processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.services.series_manager_service import SeriesManagerService
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeriesManagerServiceInit:
|
||||||
|
"""Tests for SeriesManagerService initialization."""
|
||||||
|
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_init_without_nfo_service(self, mock_serie_list):
|
||||||
|
"""Service initializes without NFO when no API key provided."""
|
||||||
|
svc = SeriesManagerService(
|
||||||
|
anime_directory="/anime",
|
||||||
|
tmdb_api_key=None,
|
||||||
|
auto_create_nfo=False,
|
||||||
|
)
|
||||||
|
assert svc.nfo_service is None
|
||||||
|
assert svc.anime_directory == "/anime"
|
||||||
|
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_init_with_nfo_disabled(self, mock_serie_list):
|
||||||
|
"""NFO service not created when auto_create and update both False."""
|
||||||
|
svc = SeriesManagerService(
|
||||||
|
anime_directory="/anime",
|
||||||
|
tmdb_api_key="key123",
|
||||||
|
auto_create_nfo=False,
|
||||||
|
update_on_scan=False,
|
||||||
|
)
|
||||||
|
assert svc.nfo_service is None
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.get_nfo_factory")
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_init_creates_nfo_service_when_enabled(
|
||||||
|
self, mock_serie_list, mock_factory_fn
|
||||||
|
):
|
||||||
|
"""NFO service is created when auto_create is True and key exists."""
|
||||||
|
mock_factory = MagicMock()
|
||||||
|
mock_nfo = MagicMock()
|
||||||
|
mock_factory.create.return_value = mock_nfo
|
||||||
|
mock_factory_fn.return_value = mock_factory
|
||||||
|
|
||||||
|
svc = SeriesManagerService(
|
||||||
|
anime_directory="/anime",
|
||||||
|
tmdb_api_key="key123",
|
||||||
|
auto_create_nfo=True,
|
||||||
|
)
|
||||||
|
assert svc.nfo_service is mock_nfo
|
||||||
|
|
||||||
|
@patch("src.core.services.nfo_factory.get_nfo_factory")
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_init_handles_nfo_factory_error(
|
||||||
|
self, mock_serie_list, mock_factory_fn
|
||||||
|
):
|
||||||
|
"""NFO service set to None if factory raises."""
|
||||||
|
mock_factory_fn.side_effect = ValueError("bad config")
|
||||||
|
svc = SeriesManagerService(
|
||||||
|
anime_directory="/anime",
|
||||||
|
tmdb_api_key="key123",
|
||||||
|
auto_create_nfo=True,
|
||||||
|
)
|
||||||
|
assert svc.nfo_service is None
|
||||||
|
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_init_stores_config_flags(self, mock_serie_list):
|
||||||
|
"""Configuration flags are stored correctly."""
|
||||||
|
svc = SeriesManagerService(
|
||||||
|
anime_directory="/anime",
|
||||||
|
auto_create_nfo=True,
|
||||||
|
update_on_scan=True,
|
||||||
|
download_poster=False,
|
||||||
|
download_logo=False,
|
||||||
|
download_fanart=True,
|
||||||
|
)
|
||||||
|
assert svc.auto_create_nfo is True
|
||||||
|
assert svc.update_on_scan is True
|
||||||
|
assert svc.download_poster is False
|
||||||
|
assert svc.download_logo is False
|
||||||
|
assert svc.download_fanart is True
|
||||||
|
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_serie_list_created_with_skip_load(self, mock_serie_list):
|
||||||
|
"""SerieList is created with skip_load=True."""
|
||||||
|
SeriesManagerService(anime_directory="/anime")
|
||||||
|
mock_serie_list.assert_called_once_with("/anime", skip_load=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromSettings:
|
||||||
|
"""Tests for from_settings classmethod."""
|
||||||
|
|
||||||
|
@patch("src.core.services.series_manager_service.settings")
|
||||||
|
@patch("src.core.services.series_manager_service.SerieList")
|
||||||
|
def test_from_settings_uses_all_settings(self, mock_serie_list, mock_settings):
|
||||||
|
"""from_settings passes all relevant settings to constructor."""
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
mock_settings.tmdb_api_key = None
|
||||||
|
mock_settings.nfo_auto_create = False
|
||||||
|
mock_settings.nfo_update_on_scan = False
|
||||||
|
mock_settings.nfo_download_poster = True
|
||||||
|
mock_settings.nfo_download_logo = True
|
||||||
|
mock_settings.nfo_download_fanart = True
|
||||||
|
mock_settings.nfo_image_size = "original"
|
||||||
|
|
||||||
|
svc = SeriesManagerService.from_settings()
|
||||||
|
assert isinstance(svc, SeriesManagerService)
|
||||||
|
assert svc.anime_directory == "/anime"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessNfoForSeries:
|
||||||
|
"""Tests for process_nfo_for_series method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
"""Create a service with mocked dependencies."""
|
||||||
|
with patch("src.core.services.series_manager_service.SerieList"):
|
||||||
|
svc = SeriesManagerService(anime_directory="/anime")
|
||||||
|
svc.nfo_service = AsyncMock()
|
||||||
|
svc.auto_create_nfo = True
|
||||||
|
return svc
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_without_nfo_service(self):
|
||||||
|
"""Does nothing when nfo_service is None."""
|
||||||
|
with patch("src.core.services.series_manager_service.SerieList"):
|
||||||
|
svc = SeriesManagerService(anime_directory="/anime")
|
||||||
|
svc.nfo_service = None
|
||||||
|
# Should not raise
|
||||||
|
await svc.process_nfo_for_series("folder", "Name", "key")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creates_nfo_when_not_exists(self, service):
|
||||||
|
"""Creates NFO file when it doesn't exist and auto_create is True."""
|
||||||
|
service.nfo_service.check_nfo_exists = AsyncMock(return_value=False)
|
||||||
|
service.nfo_service.create_tvshow_nfo = AsyncMock()
|
||||||
|
|
||||||
|
await service.process_nfo_for_series("folder", "Name", "key", year=2024)
|
||||||
|
service.nfo_service.create_tvshow_nfo.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_creation_when_exists(self, service):
|
||||||
|
"""Skips NFO creation when file already exists."""
|
||||||
|
service.nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
||||||
|
service.nfo_service.parse_nfo_ids = MagicMock(
|
||||||
|
return_value={"tmdb_id": None, "tvdb_id": None}
|
||||||
|
)
|
||||||
|
service.nfo_service.create_tvshow_nfo = AsyncMock()
|
||||||
|
|
||||||
|
await service.process_nfo_for_series("folder", "Name", "key")
|
||||||
|
service.nfo_service.create_tvshow_nfo.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_tmdb_api_error(self, service):
|
||||||
|
"""TMDBAPIError is caught and logged (not re-raised)."""
|
||||||
|
from src.core.services.tmdb_client import TMDBAPIError
|
||||||
|
service.nfo_service.check_nfo_exists = AsyncMock(
|
||||||
|
side_effect=TMDBAPIError("rate limited")
|
||||||
|
)
|
||||||
|
# Should not raise
|
||||||
|
await service.process_nfo_for_series("folder", "Name", "key")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_unexpected_error(self, service):
|
||||||
|
"""Unexpected exceptions are caught and logged."""
|
||||||
|
service.nfo_service.check_nfo_exists = AsyncMock(
|
||||||
|
side_effect=RuntimeError("unexpected")
|
||||||
|
)
|
||||||
|
await service.process_nfo_for_series("folder", "Name", "key")
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanAndProcessNfo:
|
||||||
|
"""Tests for scan_and_process_nfo method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_when_no_nfo_service(self):
|
||||||
|
"""Returns early when nfo_service is None."""
|
||||||
|
with patch("src.core.services.series_manager_service.SerieList"):
|
||||||
|
svc = SeriesManagerService(anime_directory="/anime")
|
||||||
|
svc.nfo_service = None
|
||||||
|
await svc.scan_and_process_nfo()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClose:
|
||||||
|
"""Tests for close method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_with_nfo_service(self):
|
||||||
|
"""Closes NFO service when present."""
|
||||||
|
with patch("src.core.services.series_manager_service.SerieList"):
|
||||||
|
svc = SeriesManagerService(anime_directory="/anime")
|
||||||
|
svc.nfo_service = AsyncMock()
|
||||||
|
await svc.close()
|
||||||
|
svc.nfo_service.close.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_without_nfo_service(self):
|
||||||
|
"""Close works fine when no NFO service."""
|
||||||
|
with patch("src.core.services.series_manager_service.SerieList"):
|
||||||
|
svc = SeriesManagerService(anime_directory="/anime")
|
||||||
|
svc.nfo_service = None
|
||||||
|
await svc.close() # Should not raise
|
||||||
30
tests/unit/test_templates_utils.py
Normal file
30
tests/unit/test_templates_utils.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Unit tests for template utilities and configuration.
|
||||||
|
|
||||||
|
Tests the templates module constants and the TEMPLATES_DIR path resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.server.utils.templates import TEMPLATES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplatesConfig:
|
||||||
|
"""Tests for templates module configuration."""
|
||||||
|
|
||||||
|
def test_templates_dir_is_path(self):
|
||||||
|
"""TEMPLATES_DIR is a Path object."""
|
||||||
|
assert isinstance(TEMPLATES_DIR, Path)
|
||||||
|
|
||||||
|
def test_templates_dir_ends_with_templates(self):
|
||||||
|
"""TEMPLATES_DIR path ends with 'templates'."""
|
||||||
|
assert TEMPLATES_DIR.name == "templates"
|
||||||
|
|
||||||
|
def test_templates_dir_parent_is_web(self):
|
||||||
|
"""TEMPLATES_DIR parent is 'web' directory."""
|
||||||
|
assert TEMPLATES_DIR.parent.name == "web"
|
||||||
|
|
||||||
|
def test_templates_dir_grandparent_is_server(self):
|
||||||
|
"""TEMPLATES_DIR grandparent is 'server' directory."""
|
||||||
|
assert TEMPLATES_DIR.parent.parent.name == "server"
|
||||||
Reference in New Issue
Block a user