"""Unit tests for AnimeService.delete_series().""" from __future__ import annotations import os 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() @pytest.mark.asyncio async def test_delete_series_relative_folder_with_different_cwd( self, anime_service, tmp_path ): """Regression: delete_series must work when the stored folder is relative and the process CWD differs from directory_to_search. In the container the FastAPI app runs with CWD=/app while the anime directory is /data. The DB stores the relative folder name (e.g. "Beyblade Burst (2016)"). The old code called ``os.path.abspath(folder)`` which joined against CWD=/app and produced "/app/Beyblade Burst (2016)", which was then rejected as outside the /data base. The fix resolves relative paths against the configured anime directory instead. """ safe_base = tmp_path / "data" safe_base.mkdir() series_folder = safe_base / "Beyblade Burst (2016)" series_folder.mkdir() anime_service._directory = str(safe_base) mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) # Stored folder is RELATIVE (matches what's actually in the DB) mock_series = MagicMock() mock_series.key = "beyblade-burst" mock_series.name = "Beyblade Burst" mock_series.folder = "Beyblade Burst (2016)" mock_series.id = 336 # Simulate process CWD differing from anime dir (container case: # CWD=/app while anime dir is /data). Use "/" as a stable, always- # existing CWD distinct from tmp_path. old_cwd = os.getcwd() try: os.chdir("/") 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="beyblade-burst", delete_database=False, delete_folder=True, ) finally: os.chdir(old_cwd) # Folder MUST be deleted successfully assert result.deleted_folder is True, ( f"folder delete failed: success={result.success} " f"folder_error={result.folder_error!r}" ) assert result.folder_error is None assert result.success is True assert not series_folder.exists() # ------------------------------------------------------------------ # 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", ) # ------------------------------------------------------------------ # Deletion order: filesystem first, database second # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_db_preserved_when_folder_fails( self, anime_service, tmp_path ): """When both flags are True and folder delete fails, the DB row is preserved so the user can retry after fixing the underlying issue. Previously, the database row was deleted first and the folder second. If the folder delete failed (e.g. the old CWD-relative-path bug, or any future permission/path error), the row was already gone — leaving an orphan folder on disk that could not be cleaned up through the normal delete flow. """ # Folder exists but we'll force shutil.rmtree to fail series_folder = tmp_path / "Test Series" series_folder.mkdir() anime_service._directory = str(tmp_path) 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" mock_series.id = 7 db_delete_mock = AsyncMock(return_value=True) 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=db_delete_mock, ), patch( "shutil.rmtree", side_effect=OSError("Permission denied"), ): result = await anime_service.delete_series( key="test-key", delete_database=True, delete_folder=True, ) # Folder delete failed → DB row MUST be preserved assert result.deleted_folder is False assert result.folder_error is not None assert result.deleted_from_database is False, ( "DB row was deleted despite folder delete failure — " "user would lose ability to retry the delete" ) db_delete_mock.assert_not_called() assert result.success is False # ------------------------------------------------------------------ # Orphan folder recovery (DB row gone, folder still on disk) # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_delete_series_orphan_folder_recovery(self, anime_service, tmp_path): """If the DB row is gone but a matching folder is still on disk, ``delete_folder=True`` removes the orphan folder. Reproduces the Beyblade Burst scenario: previous delete attempt removed the DB row (delete_database=True) but the folder delete silently failed (path math bug). Retrying with delete_folder=True should clean up the orphan via a key-based folder scan. """ # Simulate the on-disk anime directory anime_dir = tmp_path / "anime" anime_dir.mkdir() orphan = anime_dir / "Beyblade Burst (2016)" orphan.mkdir() (orphan / "episode.mp4").write_text("x") anime_service._directory = str(anime_dir) mock_session = AsyncMock() mock_ctx = _make_db_ctx(mock_session) # DB lookup returns None (row already gone) 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="beyblade-burst", delete_database=False, delete_folder=True, ) # Orphan folder recovered via key-match scan assert result.deleted_folder is True, ( f"orphan recovery failed: success={result.success} " f"folder_error={result.folder_error!r}" ) assert result.folder_error is None assert not orphan.exists() @pytest.mark.asyncio async def test_delete_series_orphan_folder_no_match( self, anime_service, tmp_path ): """If DB row is gone and no folder matches the key, return a clear error rather than silently succeeding. """ anime_dir = tmp_path / "anime" anime_dir.mkdir() # Some unrelated folder that does NOT match (anime_dir / "Different Show (2020)").mkdir() anime_service._directory = str(anime_dir) 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=False, delete_folder=True, ) assert result.deleted_folder is False assert result.folder_error is not None assert "no folder matching" in result.folder_error.lower() # Unrelated folder untouched assert (anime_dir / "Different Show (2020)").exists() @pytest.mark.asyncio async def test_delete_series_orphan_folder_ambiguous( self, anime_service, tmp_path ): """If multiple folders match the same normalized key, refuse to delete any of them (safe default). """ anime_dir = tmp_path / "anime" anime_dir.mkdir() (anime_dir / "Beyblade Burst (2016)").mkdir() (anime_dir / "Beyblade Burst (2019)").mkdir() # also normalizes to "beybladeburst" anime_service._directory = str(anime_dir) 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="beyblade-burst", delete_database=False, delete_folder=True, ) # Ambiguous → refuse assert result.deleted_folder is False assert result.folder_error is not None assert "no folder matching" in result.folder_error.lower() # Neither folder deleted assert (anime_dir / "Beyblade Burst (2016)").exists() assert (anime_dir / "Beyblade Burst (2019)").exists()