"""Unit tests for AnimeService.delete_series().""" from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.server.services.anime_service import AnimeService, AnimeServiceError from src.server.services.progress_service import ProgressService @pytest.fixture def mock_series_app(tmp_path): """Create a mock SeriesApp instance.""" mock_instance = MagicMock() mock_instance.directory_to_search = str(tmp_path) mock_instance.series_list = [] mock_instance.search = AsyncMock(return_value=[]) mock_instance.rescan = AsyncMock() mock_instance.download = AsyncMock(return_value=True) mock_instance.download_status = None mock_instance.scan_status = None return mock_instance @pytest.fixture def mock_progress_service(): """Create a mock ProgressService instance.""" service = MagicMock(spec=ProgressService) service.start_progress = AsyncMock() service.update_progress = AsyncMock() service.complete_progress = AsyncMock() service.fail_progress = AsyncMock() return service @pytest.fixture def anime_service(tmp_path, mock_series_app, mock_progress_service): """Create an AnimeService instance for testing.""" return AnimeService( series_app=mock_series_app, progress_service=mock_progress_service, ) # --------------------------------------------------------------------------- # Shared DB mock helpers — used inside each test's patch context # --------------------------------------------------------------------------- def _make_db_ctx(mock_session: AsyncMock) -> AsyncMock: """Build a mock async DB context manager.""" mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=None) return mock_ctx # --------------------------------------------------------------------------- # Tests — patch lives INSIDE each test method, not in a fixture # --------------------------------------------------------------------------- class TestDeleteSeriesService: """Test AnimeService.delete_series().""" # ------------------------------------------------------------------ # delete_database=True, delete_folder=False # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_db_only_success(self, anime_service): """delete_series with delete_database=True removes series from DB only.""" mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = "Test Series (2023)" mock_series.id = 42 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, return_value=True, ): result = await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=False, ) assert result.success is True assert result.key == "test-key" assert result.deleted_from_database is True assert result.deleted_folder is False assert result.database_error is None @pytest.mark.asyncio async def test_delete_db_only_calls_delete_on_series(self, anime_service): """DB delete calls AnimeSeriesService.delete(session, series_id).""" mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = "Test Series (2023)" mock_series.id = 99 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, return_value=True, ) as mock_delete: await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=False, ) mock_delete.assert_called_once() call_args = mock_delete.call_args # First positional arg should be the session assert call_args[0][0] is mock_session # Second positional arg should be the series id assert call_args[0][1] == 99 # ------------------------------------------------------------------ # delete_database=False, delete_folder=True # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_folder_only_success(self, anime_service, tmp_path): """delete_series with delete_folder=True deletes folder without touching DB.""" series_folder = tmp_path / "Test Series (2023)" series_folder.mkdir() (series_folder / "episode1.mkv").write_text("fake video") mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = str(series_folder) mock_series.id = 1 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ): result = await anime_service.delete_series( key="test-key", delete_database=False, delete_folder=True, ) assert result.success is True assert result.deleted_from_database is False assert result.deleted_folder is True assert not series_folder.exists() # Folder actually deleted @pytest.mark.asyncio async def test_delete_folder_only_no_db_delete_called(self, anime_service, tmp_path): """Folder-only delete never calls AnimeSeriesService.delete().""" series_folder = tmp_path / "Another Series" series_folder.mkdir() mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Another Series" mock_series.folder = str(series_folder) mock_series.id = 1 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, ) as mock_delete: await anime_service.delete_series( key="test-key", delete_database=False, delete_folder=True, ) mock_delete.assert_not_called() # ------------------------------------------------------------------ # delete_database=True, delete_folder=True # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_both_success(self, anime_service, tmp_path): """delete_series with both flags removes from DB and deletes folder.""" series_folder = tmp_path / "Test Series (2023)" series_folder.mkdir() (series_folder / "episode1.mkv").write_text("fake video") mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = str(series_folder) mock_series.id = 42 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, return_value=True, ): result = await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=True, ) assert result.success is True assert result.deleted_from_database is True assert result.deleted_folder is True assert result.database_error is None assert result.folder_error is None assert not series_folder.exists() # ------------------------------------------------------------------ # Series not found # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_not_found(self, anime_service): """delete_series returns failure when series doesn't exist.""" mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ): result = await anime_service.delete_series( key="nonexistent-key", delete_database=True, delete_folder=False, ) assert result.success is False assert result.deleted_from_database is False assert "not found" in result.message.lower() # ------------------------------------------------------------------ # Path traversal protection # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_path_traversal_blocked(self, anime_service, tmp_path): """delete_series refuses to delete folder outside anime base directory.""" # Create a safe folder at tmp_path, use parent as dangerous target safe_base = tmp_path / "anime_root" safe_base.mkdir() dangerous_target = tmp_path.parent # parent of anime root anime_service._directory = str(safe_base) mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test" mock_series.folder = str(dangerous_target) # outside safe_base mock_series.id = 1 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ): result = await anime_service.delete_series( key="test-key", delete_database=False, delete_folder=True, ) # Folder should NOT be deleted assert result.deleted_folder is False assert result.folder_error is not None assert "outside" in result.folder_error.lower() # ------------------------------------------------------------------ # Error handling # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_folder_delete_error(self, anime_service, tmp_path): """delete_series handles folder deletion errors gracefully.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = str(series_folder) mock_series.id = 1 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "shutil.rmtree", side_effect=OSError("Permission denied"), ): result = await anime_service.delete_series( key="test-key", delete_database=False, delete_folder=True, ) assert result.deleted_folder is False assert result.folder_error is not None @pytest.mark.asyncio async def test_delete_series_db_error_still_reports_folder( self, anime_service, tmp_path ): """Even if DB delete fails, folder deletion result is still reported.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = str(series_folder) mock_series.id = 1 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, side_effect=Exception("DB connection lost"), ): result = await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=True, ) # DB failed but folder still deleted assert result.deleted_from_database is False assert result.database_error is not None assert result.deleted_folder is True assert not series_folder.exists() # ------------------------------------------------------------------ # Edge cases # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_folder_none_when_no_folder( self, anime_service ): """delete_series with folder=True but no folder path skips folder delete.""" mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = None mock_series.id = 1 with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, return_value=True, ): result = await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=True, ) assert result.success is True assert result.deleted_from_database is True assert result.deleted_folder is False assert result.folder_error is None # No folder → no error @pytest.mark.asyncio async def test_delete_series_neither_flag_set(self, anime_service): """delete_series returns failure when neither flag is True. Needs DB patches so get_db_session doesn't raise RuntimeError, even though this test hits the early-return guard before any DB use. """ mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ): result = await anime_service.delete_series( key="any-key", delete_database=False, delete_folder=False, ) assert result.success is False assert "at least one" in result.message.lower() # ------------------------------------------------------------------ # WebSocket broadcast # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_websocket_broadcast(self, anime_service): """delete_series broadcasts series_deleted WebSocket event.""" mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) mock_series = MagicMock() mock_series.key = "test-key" mock_series.name = "Test Series" mock_series.folder = "Test Series (2023)" mock_series.id = 42 anime_service._websocket_service = MagicMock() anime_service._websocket_service.broadcast_series_deleted = AsyncMock() with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.delete", new_callable=AsyncMock, return_value=True, ): await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=False, ) anime_service._websocket_service.broadcast_series_deleted.assert_called_once_with( key="test-key", name="Test Series", )