520 lines
18 KiB
Python
520 lines
18 KiB
Python
"""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",
|
|
)
|