"""Tests for SetupService.""" import re from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.server.services.setup_service import SetupService class TestExtractYearFromFolderName: """Test _extract_year_from_folder_name method.""" def test_extracts_year_in_parentheses(self): """Folder name with (YYYY) at end → extracts year.""" assert SetupService._extract_year_from_folder_name("Attack on Titan (2013)") == 2013 assert SetupService._extract_year_from_folder_name("OnePiece (1999)") == 1999 assert SetupService._extract_year_from_folder_name("Naruto (2002)") == 2002 def test_returns_none_for_no_year(self): """Folder name without year → returns None.""" assert SetupService._extract_year_from_folder_name("OnePiece") is None assert SetupService._extract_year_from_folder_name("MyHeroAcademia") is None def test_returns_none_for_empty_string(self): """Empty string → returns None.""" assert SetupService._extract_year_from_folder_name("") is None def test_returns_none_for_invalid_year(self): """Year outside valid range → returns None.""" assert SetupService._extract_year_from_folder_name("Test (1800)") is None assert SetupService._extract_year_from_folder_name("Test (2150)") is None def test_year_must_be_four_digits(self): """Only 4-digit years are matched.""" assert SetupService._extract_year_from_folder_name("Test (23)") is None assert SetupService._extract_year_from_folder_name("Test (20231)") is None class TestExtractTitleFromFolderName: """Test _extract_title_from_folder_name method.""" def test_removes_year_suffix(self): """Folder name with (YYYY) → title without year.""" assert SetupService._extract_title_from_folder_name("Attack on Titan (2013)") == "Attack on Titan" assert SetupService._extract_title_from_folder_name("OnePiece (1999)") == "OnePiece" def test_preserves_title_without_year(self): """Folder name without year → unchanged title.""" assert SetupService._extract_title_from_folder_name("OnePiece") == "OnePiece" def test_strips_whitespace(self): """Year suffix with whitespace → properly stripped.""" assert SetupService._extract_title_from_folder_name("Test (2020) ") == "Test" assert SetupService._extract_title_from_folder_name(" Test (2020)") == "Test" def test_returns_empty_for_empty_string(self): """Empty string → returns empty string.""" assert SetupService._extract_title_from_folder_name("") == "" class TestResolveKeyViaSearch: """Test _resolve_key_via_search method.""" @pytest.mark.asyncio async def test_returns_key_when_single_exact_match(self): """Search returns 1 result with same name → returns key.""" mock_series_app = AsyncMock() mock_series_app.search.return_value = [ {'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'} ] with patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ): result = await SetupService._resolve_key_via_search("Attack on Titan") assert result == 'attack-on-titan' @pytest.mark.asyncio async def test_returns_empty_when_no_results(self): """Search returns 0 results → returns empty string.""" mock_series_app = AsyncMock() mock_series_app.search.return_value = [] with patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ): result = await SetupService._resolve_key_via_search("Unknown Series") assert result == '' @pytest.mark.asyncio async def test_returns_empty_when_multiple_results(self): """Search returns >1 results → returns empty string.""" mock_series_app = AsyncMock() mock_series_app.search.return_value = [ {'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}, {'title': 'Attack on Titan OVA', 'link': '/anime/stream/attack-on-titan-ova'} ] with patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ): result = await SetupService._resolve_key_via_search("Attack on Titan") assert result == '' @pytest.mark.asyncio async def test_returns_empty_when_name_mismatch(self): """Search returns 1 result but name differs (case-insensitive) → returns empty string.""" mock_series_app = AsyncMock() mock_series_app.search.return_value = [ {'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'} ] with patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ): result = await SetupService._resolve_key_via_search("attack on titan") # lowercase # Should still match since comparison is case-insensitive assert result == 'attack-on-titan' @pytest.mark.asyncio async def test_returns_empty_when_empty_title(self): """Empty title → returns empty string without calling search.""" result = await SetupService._resolve_key_via_search("") assert result == '' @pytest.mark.asyncio async def test_handles_search_exception(self): """Search raises exception → returns empty string.""" mock_series_app = AsyncMock() mock_series_app.search.side_effect = Exception("Network error") with patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ): result = await SetupService._resolve_key_via_search("Test") assert result == '' class TestSetupServiceRun: """Test SetupService.run method.""" @pytest.mark.asyncio async def test_creates_series_for_new_folders(self, tmp_path): """Folders without DB entries and single search match → creates AnimeSeries records. Note: This test verifies the logic flow when search returns a single match. The actual search call goes through SeriesApp which uses run_in_executor, so we test the flow with a resolved key being passed through. """ anime_dir = tmp_path / "anime" anime_dir.mkdir() (anime_dir / "Attack on Titan (2013)").mkdir() mock_db = AsyncMock() mock_get_db = MagicMock() mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aexit__.return_value = None with patch( 'src.server.services.setup_service.settings' ) as mock_settings, \ patch( 'src.server.services.setup_service.get_db_session', return_value=mock_get_db ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.create', new_callable=AsyncMock ) as mock_create: mock_settings.anime_directory = str(anime_dir) # Directly test the flow by patching _resolve_key_via_search # to return a key (simulating successful search) with patch.object( SetupService, '_resolve_key_via_search', new_callable=AsyncMock, return_value='attack-on-titan' ): result = await SetupService.run() assert result == 1 mock_create.assert_called_once() call_kwargs = mock_create.call_args.kwargs assert call_kwargs['key'] == 'attack-on-titan' @pytest.mark.asyncio async def test_skips_existing_folders(self, tmp_path): """Folder already in DB → skipped.""" anime_dir = tmp_path / "anime" anime_dir.mkdir() (anime_dir / "Attack on Titan (2013)").mkdir() mock_series_app = AsyncMock() mock_series_app.search.return_value = [] mock_db = AsyncMock() mock_get_db = MagicMock() mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aexit__.return_value = None with patch( 'src.server.services.setup_service.settings' ) as mock_settings, \ patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ), \ patch( 'src.server.services.setup_service.get_db_session', return_value=mock_get_db ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', new_callable=AsyncMock, return_value=MagicMock() # existing ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.create', new_callable=AsyncMock ) as mock_create: mock_settings.anime_directory = str(anime_dir) result = await SetupService.run() assert result == 0 mock_create.assert_not_called() @pytest.mark.asyncio async def test_resolves_key_for_single_match(self, tmp_path): """Single search match with same name → uses that key. This tests that when _resolve_key_via_search returns a key, the series is created with that key. """ anime_dir = tmp_path / "anime" anime_dir.mkdir() (anime_dir / "Attack on Titan (2013)").mkdir() mock_db = AsyncMock() mock_get_db = MagicMock() mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aexit__.return_value = None with patch( 'src.server.services.setup_service.settings' ) as mock_settings, \ patch( 'src.server.services.setup_service.get_db_session', return_value=mock_get_db ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.create', new_callable=AsyncMock ) as mock_create: mock_settings.anime_directory = str(anime_dir) # Simulate successful search returning a key with patch.object( SetupService, '_resolve_key_via_search', new_callable=AsyncMock, return_value='attack-on-titan' ): await SetupService.run() # Verify create was called with resolved key call_kwargs = mock_create.call_args.kwargs assert call_kwargs['key'] == 'attack-on-titan' assert call_kwargs['name'] == 'Attack on Titan' assert call_kwargs['year'] == 2013 @pytest.mark.asyncio async def test_tracks_unresolved_when_no_match(self, tmp_path): """No search match → tracks folder as unresolved, doesn't create series.""" anime_dir = tmp_path / "anime" anime_dir.mkdir() (anime_dir / "Unknown Series (2020)").mkdir() mock_series_app = AsyncMock() mock_series_app.search.return_value = [] mock_db = AsyncMock() mock_get_db = MagicMock() mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aexit__.return_value = None with patch( 'src.server.services.setup_service.settings' ) as mock_settings, \ patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ), \ patch( 'src.server.services.setup_service.get_db_session', return_value=mock_get_db ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.UnresolvedFolderService.create', new_callable=AsyncMock ) as mock_create_unresolved: mock_settings.anime_directory = str(anime_dir) result = await SetupService.run() # Should return 0 since no series was created assert result == 0 # Should track as unresolved instead of creating series mock_create_unresolved.assert_called_once() call_kwargs = mock_create_unresolved.call_args.kwargs assert call_kwargs['folder_name'] == 'Unknown Series (2020)' assert call_kwargs['title'] == 'Unknown Series' assert call_kwargs['year'] == 2020 @pytest.mark.asyncio async def test_returns_zero_when_directory_not_configured(self): """anime_directory not configured → returns 0.""" with patch( 'src.server.services.setup_service.settings' ) as mock_settings: mock_settings.anime_directory = "" result = await SetupService.run() assert result == 0 @pytest.mark.asyncio async def test_returns_zero_when_directory_not_exist(self, tmp_path): """anime_directory doesn't exist → returns 0.""" fake_dir = tmp_path / "nonexistent" with patch( 'src.server.services.setup_service.settings' ) as mock_settings: mock_settings.anime_directory = str(fake_dir) result = await SetupService.run() assert result == 0 @pytest.mark.asyncio async def test_skips_files_only_processes_directories(self, tmp_path): """Non-directory items in anime folder → skipped.""" anime_dir = tmp_path / "anime" anime_dir.mkdir() (anime_dir / "Valid Series (2020)").mkdir() (anime_dir / "random_file.txt").touch() mock_series_app = AsyncMock() mock_series_app.search.return_value = [] mock_db = AsyncMock() mock_get_db = MagicMock() mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aexit__.return_value = None with patch( 'src.server.services.setup_service.settings' ) as mock_settings, \ patch( 'src.server.services.setup_service.get_series_app', return_value=mock_series_app ), \ patch( 'src.server.services.setup_service.get_db_session', return_value=mock_get_db ), \ patch( 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', new_callable=AsyncMock, return_value=None ), \ patch( 'src.server.services.setup_service.UnresolvedFolderService.create', new_callable=AsyncMock ) as mock_create_unresolved: mock_settings.anime_directory = str(anime_dir) result = await SetupService.run() # Empty search results → folder tracked as unresolved, not created assert result == 0 mock_create_unresolved.assert_called_once() class TestCheckNfoFile: """Test _check_nfo_file method.""" def test_returns_true_when_tvshow_nfo_exists(self, tmp_path): """tvshow.nfo exists → returns (True, path, created, updated).""" folder = tmp_path / "Series" folder.mkdir() nfo_file = folder / "tvshow.nfo" nfo_file.touch() has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder) assert has_nfo is True assert nfo_path == str(nfo_file) assert created is not None assert updated is not None def test_returns_false_when_no_nfo(self, tmp_path): """No tvshow.nfo → returns (False, None, None, None).""" folder = tmp_path / "Series" folder.mkdir() has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder) assert has_nfo is False assert nfo_path is None assert created is None assert updated is None def test_returns_false_when_nfo_is_directory(self, tmp_path): """tvshow.nfo exists but is a directory → returns False.""" folder = tmp_path / "Series" folder.mkdir() (folder / "tvshow.nfo").mkdir() has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder) assert has_nfo is False class TestCheckLogoFile: """Test _check_logo_file method.""" def test_returns_true_when_logo_png_exists(self, tmp_path): """logo.png exists → returns True.""" folder = tmp_path / "Series" folder.mkdir() (folder / "logo.png").touch() assert SetupService._check_logo_file(folder) is True def test_returns_false_when_no_logo(self, tmp_path): """No logo.png → returns False.""" folder = tmp_path / "Series" folder.mkdir() assert SetupService._check_logo_file(folder) is False def test_returns_false_for_other_files(self, tmp_path): """Files like logo.jpg or logo.gif → returns False.""" folder = tmp_path / "Series" folder.mkdir() (folder / "logo.jpg").touch() (folder / "logo.gif").touch() assert SetupService._check_logo_file(folder) is False class TestCheckImageFiles: """Test _check_image_files method.""" def test_returns_true_for_poster_jpg(self, tmp_path): """poster.jpg exists → returns True.""" folder = tmp_path / "Series" folder.mkdir() (folder / "poster.jpg").touch() assert SetupService._check_image_files(folder) is True def test_returns_true_for_poster_jpeg(self, tmp_path): """poster.jpeg exists → returns True.""" folder = tmp_path / "Series" folder.mkdir() (folder / "poster.jpeg").touch() assert SetupService._check_image_files(folder) is True def test_returns_true_for_poster_png(self, tmp_path): """poster.png exists → returns True.""" folder = tmp_path / "Series" folder.mkdir() (folder / "poster.png").touch() assert SetupService._check_image_files(folder) is True def test_returns_true_for_fanart_jpg(self, tmp_path): """fanart.jpg exists → returns True.""" folder = tmp_path / "Series" folder.mkdir() (folder / "fanart.jpg").touch() assert SetupService._check_image_files(folder) is True def test_returns_false_when_no_images(self, tmp_path): """No poster or fanart images → returns False.""" folder = tmp_path / "Series" folder.mkdir() (folder / "episode_01.mp4").touch() assert SetupService._check_image_files(folder) is False def test_returns_false_for_unrelated_files(self, tmp_path): """Files not matching poster/fanart pattern → returns False.""" folder = tmp_path / "Series" folder.mkdir() (folder / "banner.png").touch() (folder / "thumbnail.jpg").touch() assert SetupService._check_image_files(folder) is False class TestGetSeriesProperties: """Test _get_series_properties method.""" def test_returns_all_properties_from_filesystem(self, tmp_path): """Folder with tvshow.nfo, logo.png, poster.jpg → returns correct props.""" folder = tmp_path / "Series" folder.mkdir() (folder / "tvshow.nfo").touch() (folder / "logo.png").touch() (folder / "poster.jpg").touch() props = SetupService._get_series_properties(folder) assert props.has_nfo is True assert props.nfo_path is not None assert props.logo_loaded is True assert props.images_loaded is True def test_returns_defaults_when_no_files(self, tmp_path): """Empty folder → returns all False/None.""" folder = tmp_path / "Series" folder.mkdir() props = SetupService._get_series_properties(folder) assert props.has_nfo is False assert props.nfo_path is None assert props.logo_loaded is False assert props.images_loaded is False