401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""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)
|