added delete option

This commit is contained in:
2026-08-16 19:53:38 +02:00
parent 46e8b2c9eb
commit a7ed2c999c
22 changed files with 3265 additions and 3 deletions

View File

@@ -0,0 +1,519 @@
"""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",
)