"""Tests for DELETE /api/anime/{key} endpoint.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import ASGITransport, AsyncClient from src.server.api import anime as anime_module from src.server.fastapi_app import app from src.server.services.auth_service import auth_service class FakeSerie: """Mock Serie object for testing.""" def __init__(self, key, name, folder, episodeDict=None): self.key = key self.name = name self.folder = folder self.episodeDict = episodeDict or {} self.site = "aniworld.to" class FakeSeriesApp: """Mock SeriesApp for testing.""" def __init__(self): self.list = self self.serie_scanner = MagicMock() self.directory = "/tmp/fake_anime" self.keyDict = {} self._items = [ FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}), ] for item in self._items: self.keyDict[item.key] = item def GetList(self): return self._items @pytest.fixture async def authenticated_client(): """Return an async HTTP client authenticated with a valid token.""" if not auth_service.is_configured(): auth_service.setup_master_password("TestPass123!") async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: r = await ac.post("/api/auth/login", json={"password": "TestPass123!"}) assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}" token = r.json()["access_token"] ac.headers["Authorization"] = f"Bearer {token}" yield ac @pytest.fixture def mock_series_app(): """Create a mock SeriesApp for dependency injection.""" return FakeSeriesApp() class TestDeleteAnimeEndpoint: """Tests for DELETE /api/anime/{key}.""" @pytest.mark.asyncio async def test_delete_requires_authentication(self): """DELETE without token returns 401.""" async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: r = await ac.request( "DELETE", "/api/anime/test-key", json={"delete_database": True, "delete_folder": False, "confirm_text": "delete"}, ) assert r.status_code == 401 @pytest.mark.asyncio async def test_delete_invalid_confirm_text(self, authenticated_client): """DELETE with wrong confirm_text returns 400.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "DELETE", # Wrong case }, ) assert r.status_code == 400 assert "delete" in r.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_invalid_confirm_text_empty(self, authenticated_client): """DELETE with empty confirm_text returns 400.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "", }, ) assert r.status_code == 400 @pytest.mark.asyncio async def test_delete_no_option_selected(self, authenticated_client): """DELETE with both flags False returns 400.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-key", json={ "delete_database": False, "delete_folder": False, "confirm_text": "delete", }, ) assert r.status_code == 400 assert "at least one" in r.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_database_only_success(self, authenticated_client): """DELETE with delete_database=True succeeds and returns result.""" from src.server.models.anime import DeleteSeriesResult mock_result = DeleteSeriesResult( success=True, key="test-show-key", name="Test Show", deleted_from_database=True, deleted_folder=False, folder_path=None, database_error=None, folder_error=None, message="Removed from database.", ) with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_svc.delete_series = AsyncMock(return_value=mock_result) mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "delete", }, ) assert r.status_code == 200 data = r.json() assert data["success"] is True assert data["key"] == "test-show-key" assert data["deleted_from_database"] is True assert data["deleted_folder"] is False @pytest.mark.asyncio async def test_delete_folder_only_success(self, authenticated_client): """DELETE with delete_folder=True succeeds.""" from src.server.models.anime import DeleteSeriesResult mock_result = DeleteSeriesResult( success=True, key="test-show-key", name="Test Show", deleted_from_database=False, deleted_folder=True, folder_path="/anime/Test Show", database_error=None, folder_error=None, message="Folder deleted.", ) with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_svc.delete_series = AsyncMock(return_value=mock_result) mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": False, "delete_folder": True, "confirm_text": "delete", }, ) assert r.status_code == 200 data = r.json() assert data["deleted_folder"] is True assert data["deleted_from_database"] is False @pytest.mark.asyncio async def test_delete_both_success(self, authenticated_client): """DELETE with both flags True succeeds.""" from src.server.models.anime import DeleteSeriesResult mock_result = DeleteSeriesResult( success=True, key="test-show-key", name="Test Show", deleted_from_database=True, deleted_folder=True, folder_path="/anime/Test Show", database_error=None, folder_error=None, message="Removed from database and folder deleted.", ) with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_svc.delete_series = AsyncMock(return_value=mock_result) mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": True, "confirm_text": "delete", }, ) assert r.status_code == 200 data = r.json() assert data["deleted_from_database"] is True assert data["deleted_folder"] is True @pytest.mark.asyncio async def test_delete_series_not_found(self, authenticated_client): """DELETE with unknown key returns 404.""" from src.server.models.anime import DeleteSeriesResult mock_result = DeleteSeriesResult( success=False, key="nonexistent-key", name="", deleted_from_database=False, deleted_folder=False, folder_path=None, database_error=None, folder_error=None, message="Series not found.", ) with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_svc.delete_series = AsyncMock(return_value=mock_result) mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/nonexistent-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "delete", }, ) assert r.status_code == 404 assert "not found" in r.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_service_error_returns_500(self, authenticated_client): """DELETE when service raises unexpected error returns 500.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_svc.delete_series = AsyncMock(side_effect=RuntimeError("Unexpected error")) mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "delete", }, ) assert r.status_code == 500 assert "delete failed" in r.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_body_confirm_text_required(self, authenticated_client): """DELETE body must contain confirm_text field.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc # Missing confirm_text entirely r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": False, }, ) # Pydantic validation error assert r.status_code in (400, 422) @pytest.mark.asyncio async def test_delete_confirm_text_too_long(self, authenticated_client): """DELETE with extremely long confirm_text is rejected.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "delete" + "x" * 10000, }, ) # Should be rejected as invalid confirm_text assert r.status_code in (400, 422) @pytest.mark.asyncio async def test_delete_confirm_text_with_whitespace_rejected(self, authenticated_client): """DELETE with whitespace-padded confirm_text is rejected.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": " delete ", # Has spaces }, ) assert r.status_code == 400 assert "delete" in r.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_confirm_text_with_newline_rejected(self, authenticated_client): """DELETE with newline in confirm_text is rejected.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/test-show-key", json={ "delete_database": True, "delete_folder": False, "confirm_text": "delet\ne", # Has newline }, ) assert r.status_code == 400 @pytest.mark.asyncio async def test_delete_path_traversal_in_key_rejected(self, authenticated_client): """DELETE with path traversal key returns 400 or 404.""" with patch('src.server.api.anime.get_anime_service') as mock_get_svc: mock_svc = MagicMock() mock_svc.delete_series = AsyncMock(side_effect=ValueError("Path traversal detected")) mock_get_svc.return_value = mock_svc r = await authenticated_client.request( "DELETE", "/api/anime/../../../etc/passwd", json={ "delete_database": True, "delete_folder": False, "confirm_text": "delete", }, ) # Should either be rejected by the service or return an error assert r.status_code in (400, 404, 422)