added delete option
This commit is contained in:
400
tests/api/test_delete_anime_endpoint.py
Normal file
400
tests/api/test_delete_anime_endpoint.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user