delete_series() removed the row from the database and the folder from disk, but never evicted the entry from SerieList.keyDict — the in-memory cache that list_series_with_filters() reads from via SeriesApp.list.GetList(). As a result /api/anime kept returning the deleted series on every page reload (the 'Beyblade Burst still listed after delete' bug). Changes: * Add SerieList.remove(key) so the cache has a proper eviction API. * In delete_series(), call it (or fall back to keyDict.pop) after a successful DB delete. * Broadcast a broader series_list_changed event so any client that missed the specific series_deleted event can re-sync by re-fetching /api/anime. Front-end: new SERIES_LIST_CHANGED constant, handler that triggers SeriesManager.reloadSeries(). * Two new regression tests: one asserting the in-memory cache is pruned, one asserting the broader broadcast fires.
876 lines
32 KiB
Python
876 lines
32 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
|
|
|
|
# ------------------------------------------------------------------
|
|
# In-memory keyDict cache eviction
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_series_evicts_in_memory_cache(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""After a successful DB delete, the in-memory SerieList.keyDict
|
|
entry for that series must be removed.
|
|
|
|
The /api/anime list endpoint reads from SeriesApp.list.keyDict
|
|
via list_series_with_filters(). If the cache is not pruned, the
|
|
deleted series keeps appearing in the listing on every page
|
|
reload — exactly the Beyblade Burst bug.
|
|
"""
|
|
from src.server.database.SerieList import SerieList
|
|
|
|
mock_session = AsyncMock()
|
|
mock_ctx = _make_db_ctx(mock_session)
|
|
|
|
mock_series = MagicMock()
|
|
mock_series.key = "beyblade-burst-rise"
|
|
mock_series.name = "Beyblade Burst Rise"
|
|
mock_series.folder = "Beyblade Burst Rise (2016)"
|
|
mock_series.id = 487
|
|
|
|
# Use a real SerieList instance — the same type the production
|
|
# code mutates — so the eviction actually exercises the real
|
|
# remove() method (a MagicMock would just return more mocks).
|
|
real_list = SerieList(str(mock_series_app.directory_to_search))
|
|
real_list.keyDict["beyblade-burst-rise"] = mock_series
|
|
mock_series_app.list = real_list
|
|
|
|
anime_service._websocket_service = MagicMock()
|
|
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
|
anime_service._websocket_service.broadcast_series_list_changed = 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,
|
|
):
|
|
result = await anime_service.delete_series(
|
|
key="beyblade-burst-rise",
|
|
delete_database=True,
|
|
delete_folder=False,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert "beyblade-burst-rise" not in real_list.keyDict, (
|
|
"In-memory cache still contains the deleted series — "
|
|
"/api/anime will keep returning it after a page reload"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_series_broadcasts_series_list_changed(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""A stronger ``series_list_changed`` broadcast fires after delete
|
|
so any connected client can re-sync without relying on the more
|
|
specific ``series_deleted`` event reaching them.
|
|
"""
|
|
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 = 1
|
|
|
|
mock_series_app.list.keyDict = {"test-key": mock_series}
|
|
|
|
anime_service._websocket_service = MagicMock()
|
|
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
|
anime_service._websocket_service.broadcast_series_list_changed = 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_list_changed.assert_called_once()
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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()
|