diff --git a/tests/unit/test_error_controller.py b/tests/unit/test_error_controller.py new file mode 100644 index 0000000..6774ac5 --- /dev/null +++ b/tests/unit/test_error_controller.py @@ -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) diff --git a/tests/unit/test_media_utils.py b/tests/unit/test_media_utils.py new file mode 100644 index 0000000..d400f21 --- /dev/null +++ b/tests/unit/test_media_utils.py @@ -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 diff --git a/tests/unit/test_nfo_factory.py b/tests/unit/test_nfo_factory.py new file mode 100644 index 0000000..fe88898 --- /dev/null +++ b/tests/unit/test_nfo_factory.py @@ -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 diff --git a/tests/unit/test_series_manager_service.py b/tests/unit/test_series_manager_service.py new file mode 100644 index 0000000..6cb839f --- /dev/null +++ b/tests/unit/test_series_manager_service.py @@ -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 diff --git a/tests/unit/test_templates_utils.py b/tests/unit/test_templates_utils.py new file mode 100644 index 0000000..1b6f94d --- /dev/null +++ b/tests/unit/test_templates_utils.py @@ -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"