Two follow-ups to the previous fix that resolved the CWD-relative-path bug in delete_series: 1. Reorder deletion: filesystem first, database second. When both delete_database=True and delete_folder=True were set and the folder delete failed (any reason — path math bug, permission error, missing volume, etc.), the database row was already gone by the time the folder delete was attempted. This left an orphan folder on disk that the user had no normal way to clean up — the delete UI returns 'Series not found' when the row is gone. Doing folder delete first means a folder-side failure preserves the DB row, so the user can retry the delete once the underlying issue is resolved. 2. Orphan-folder recovery: when the user requests delete_folder=True for a key whose DB row no longer exists, scan the configured anime directory for a folder that uniquely matches the key (normalized: strip trailing (YYYY), lowercase, remove hyphens/underscores/non-alphanumerics) and delete it. This recovers the Beyblade Burst scenario: a previous delete attempt removed the row but the folder stayed on disk; retrying the delete now cleans up the orphan. Safety: recovery only triggers when exactly one folder matches the normalized key. Zero matches → clear 'no folder matching' error. Multiple matches (ambiguous) → refuses to delete anything. Adds four regression tests: - test_delete_series_db_preserved_when_folder_fails - test_delete_series_orphan_folder_recovery (the Beyblade Burst case) - test_delete_series_orphan_folder_no_match - test_delete_series_orphan_folder_ambiguous
771 lines
28 KiB
Python
771 lines
28 KiB
Python
"""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()
|