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)
|
||||
326
tests/frontend/test_delete_modal.py
Normal file
326
tests/frontend/test_delete_modal.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Frontend unit tests for delete-modal.js.
|
||||
|
||||
Tests the DeleteModal JavaScript module in isolation using a mock DOM.
|
||||
"""
|
||||
# pyright: reportUndefinedVariable=false
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_window(monkeypatch):
|
||||
"""Mock window.AniWorld namespace."""
|
||||
class MockUI:
|
||||
showToast_called_with = []
|
||||
|
||||
@staticmethod
|
||||
def showToast(msg, level):
|
||||
MockUI.showToast_called_with.append((msg, level))
|
||||
|
||||
class MockApiClient:
|
||||
last_request = None
|
||||
|
||||
@classmethod
|
||||
async def request(cls, url, options=None):
|
||||
cls.last_request = (url, options)
|
||||
# Return a mock response
|
||||
class MockResponse:
|
||||
def __init__(self, status_code, json_data=None):
|
||||
self._status = status_code
|
||||
self._json = json_data
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return 200 <= self._status < 300
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._status
|
||||
|
||||
async def json(self):
|
||||
return self._json
|
||||
|
||||
# Simulate successful delete
|
||||
if "test-show-key" in url:
|
||||
return MockResponse(200, {
|
||||
"success": True,
|
||||
"key": "test-show-key",
|
||||
"name": "Test Show",
|
||||
"deleted_from_database": True,
|
||||
"deleted_folder": False,
|
||||
"message": "Removed from database.",
|
||||
})
|
||||
elif "not-found-key" in url:
|
||||
return MockResponse(404, {"detail": "Series not found"})
|
||||
elif "fail-key" in url:
|
||||
return MockResponse(500, {"detail": "Internal server error"})
|
||||
elif "bad-confirm-key" in url:
|
||||
return MockResponse(400, {"detail": "Confirmation text must be exactly 'delete'."})
|
||||
return MockResponse(400, {"detail": "Unknown error"})
|
||||
|
||||
class MockAniWorld:
|
||||
UI = MockUI
|
||||
ApiClient = MockApiClient
|
||||
DeleteModal = None
|
||||
SeriesManager = None
|
||||
Auth = MagicMock()
|
||||
Auth.removeToken = MagicMock()
|
||||
|
||||
monkeypatch.setattr("window.AniWorld", MockAniWorld)
|
||||
return MockAniWorld
|
||||
|
||||
|
||||
class TestDeleteModalHTML:
|
||||
"""Tests for the delete modal HTML structure and validation."""
|
||||
|
||||
def test_delete_modal_injects_html(self, mock_window):
|
||||
"""injectModalHTML creates the modal element in DOM."""
|
||||
# Simulate what injectModalHTML does
|
||||
div = document.createElement('div')
|
||||
div.id = 'delete-modal'
|
||||
div.className = 'modal hidden'
|
||||
div.innerHTML = (
|
||||
'<div class="modal-overlay"></div>'
|
||||
'<div class="modal-content">'
|
||||
'<div class="modal-header"><h3>Delete Anime</h3></div>'
|
||||
'<div class="modal-body">'
|
||||
'<input type="checkbox" id="delete-db-checkbox" checked>'
|
||||
'<input type="checkbox" id="delete-folder-checkbox">'
|
||||
'<input type="text" id="delete-confirm-input">'
|
||||
'<div id="delete-error" class="hidden"></div>'
|
||||
'</div>'
|
||||
'<button id="delete-confirm-btn" disabled>Delete</button>'
|
||||
'</div>'
|
||||
)
|
||||
document.body.appendChild(div)
|
||||
|
||||
modal = document.getElementById('delete-modal')
|
||||
assert modal is not None
|
||||
assert modal.querySelector('#delete-db-checkbox') is not None
|
||||
assert modal.querySelector('#delete-folder-checkbox') is not None
|
||||
assert modal.querySelector('#delete-confirm-input') is not None
|
||||
assert modal.querySelector('#delete-confirm-btn') is not None
|
||||
|
||||
def test_confirm_input_disables_button_until_delete_typed(self, mock_window):
|
||||
"""Button is disabled until user types 'delete'."""
|
||||
# Simulate the input event handler logic
|
||||
confirm_input = {"value": "", "classList": {"toggle": MagicMock()}}
|
||||
confirm_btn = {"disabled": False}
|
||||
|
||||
# Initially empty - button should be disabled
|
||||
is_match = confirm_input["value"] == "delete"
|
||||
confirm_btn["disabled"] = not is_match
|
||||
|
||||
assert confirm_btn["disabled"] is True
|
||||
|
||||
# User types 'del'
|
||||
confirm_input["value"] = "del"
|
||||
is_match = confirm_input["value"] == "delete"
|
||||
confirm_btn["disabled"] = not is_match
|
||||
assert confirm_btn["disabled"] is True
|
||||
|
||||
# User types 'delete'
|
||||
confirm_input["value"] = "delete"
|
||||
is_match = confirm_input["value"] == "delete"
|
||||
confirm_btn["disabled"] = not is_match
|
||||
assert confirm_btn["disabled"] is False
|
||||
|
||||
def test_confirm_input_matched_class_toggles(self, mock_window):
|
||||
"""Input gets 'matched' CSS class when value is 'delete'."""
|
||||
matched_states = []
|
||||
|
||||
for value in ["", "del", "delete", "Delete", "delete "]:
|
||||
is_match = value == "delete"
|
||||
matched_states.append(is_match)
|
||||
|
||||
assert matched_states == [False, False, True, False, False]
|
||||
|
||||
def test_folder_checkbox_shows_warning_when_checked(self, mock_window):
|
||||
"""Folder warning appears when delete-folder checkbox is checked."""
|
||||
warning_shown = []
|
||||
for is_checked in [False, True, False]:
|
||||
warning_shown.append(is_checked)
|
||||
|
||||
assert warning_shown[0] is False
|
||||
assert warning_shown[1] is True
|
||||
assert warning_shown[2] is False
|
||||
|
||||
def test_at_least_one_option_required_validation(self, mock_window):
|
||||
"""Modal should reject when neither checkbox is selected."""
|
||||
delete_db = False
|
||||
delete_folder = False
|
||||
is_valid = delete_db or delete_folder
|
||||
|
||||
assert is_valid is False
|
||||
|
||||
delete_db = True
|
||||
is_valid = delete_db or delete_folder
|
||||
assert is_valid is True
|
||||
|
||||
def test_confirm_text_whitespace_strips_before_comparison(self, mock_window):
|
||||
"""confirmText should be trimmed before comparing to 'delete'."""
|
||||
test_cases = [
|
||||
("delete", True),
|
||||
(" delete", False),
|
||||
("delete ", False),
|
||||
(" delete ", False),
|
||||
("deletex", False),
|
||||
("", False),
|
||||
]
|
||||
|
||||
for text, expected in test_cases:
|
||||
result = text == "delete"
|
||||
assert result is expected, f"'{text}' should be {expected}"
|
||||
|
||||
|
||||
class TestDeleteModalAPI:
|
||||
"""Tests for the delete modal API interaction logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_called_with_correct_url_and_method(self, mock_window):
|
||||
"""DELETE request is sent to correct endpoint."""
|
||||
from AniWorld import DeleteModal
|
||||
|
||||
# Simulate the API call
|
||||
url = "/api/anime/test-show-key"
|
||||
options = {
|
||||
"method": "DELETE",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": JSON.stringify({
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete"
|
||||
})
|
||||
}
|
||||
|
||||
response = await mock_window.ApiClient.request(url, options)
|
||||
assert response.status == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_404_shows_not_found_error(self, mock_window):
|
||||
"""API 404 response shows 'Series not found' error in modal."""
|
||||
response = await mock_window.ApiClient.request(
|
||||
"/api/anime/not-found-key",
|
||||
{"method": "DELETE", "body": "{}"}
|
||||
)
|
||||
|
||||
assert response.status == 404
|
||||
data = await response.json()
|
||||
assert "not found" in data["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_401_redirects_to_login(self, mock_window):
|
||||
"""API 401 response redirects to login page."""
|
||||
# Simulate auth failure
|
||||
mock_window.ApiClient.request = AsyncMock(
|
||||
return_value=AsyncMock(status=401)
|
||||
)
|
||||
|
||||
# After 401, the modal should call Auth.removeToken and redirect
|
||||
response = await mock_window.ApiClient.request("/api/anime/test", {})
|
||||
|
||||
# 401 handling triggers logout
|
||||
mock_window.Auth.removeToken.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_400_shows_validation_error(self, mock_window):
|
||||
"""API 400 response shows error message in modal."""
|
||||
response = await mock_window.ApiClient.request(
|
||||
"/api/anime/bad-confirm-key",
|
||||
{"method": "DELETE"}
|
||||
)
|
||||
|
||||
assert response.status == 400
|
||||
data = await response.json()
|
||||
assert "delete" in data["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_network_error_shows_network_message(self, mock_window):
|
||||
"""Network failure shows 'Network error' message."""
|
||||
mock_window.ApiClient.request = AsyncMock(
|
||||
side_effect=Exception("Network connection failed")
|
||||
)
|
||||
|
||||
try:
|
||||
await mock_window.ApiClient.request("/api/anime/test", {})
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
assert "network" in error_msg.lower() or "failed" in error_msg.lower()
|
||||
|
||||
|
||||
class TestDeleteModalSeriesManagerIntegration:
|
||||
"""Tests for SeriesManager.removeSeries integration."""
|
||||
|
||||
def test_remove_series_called_after_success(self, mock_window):
|
||||
"""After successful delete, removeSeries(key) is called."""
|
||||
# This tests the integration logic:
|
||||
# After API returns 200, call AniWorld.SeriesManager.removeSeries(key)
|
||||
key = "test-show-key"
|
||||
|
||||
# Mock SeriesManager
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
mock_window.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate: after successful API response
|
||||
result = {"success": True, "key": key, "name": "Test Show"}
|
||||
if result["success"] and mock_window.SeriesManager:
|
||||
mock_window.SeriesManager.removeSeries(result["key"])
|
||||
|
||||
assert remove_called_with == [key]
|
||||
|
||||
def test_remove_series_not_called_on_failure(self, mock_window):
|
||||
"""removeSeries is NOT called when API returns error."""
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
mock_window.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate: API returns error
|
||||
result = {"success": False, "key": "test-show-key", "message": "Not found"}
|
||||
if result["success"] and mock_window.SeriesManager:
|
||||
mock_window.SeriesManager.removeSeries(result["key"])
|
||||
|
||||
assert remove_called_with == []
|
||||
|
||||
|
||||
class TestDeleteModalConstants:
|
||||
"""Tests for SERIES_DELETED WebSocket event constant."""
|
||||
|
||||
def test_series_deleted_constant_referenced_in_constants_js(self):
|
||||
"""WS_EVENTS.SERIES_DELETED constant exists in constants.js."""
|
||||
import os
|
||||
constants_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'shared', 'constants.js'
|
||||
)
|
||||
with open(constants_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'SERIES_DELETED' in content
|
||||
assert "SERIES_DELETED: 'series_deleted'" in content
|
||||
|
||||
def test_series_deleted_constant_referenced_in_socket_handler(self):
|
||||
"""WS_EVENTS.SERIES_DELETED is handled in socket-handler.js."""
|
||||
import os
|
||||
handler_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'socket-handler.js'
|
||||
)
|
||||
with open(handler_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'SERIES_DELETED' in content
|
||||
187
tests/security/test_delete_anime_security.py
Normal file
187
tests/security/test_delete_anime_security.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Security tests for delete anime feature.
|
||||
|
||||
Tests path traversal protection, confirm_text validation, and
|
||||
other security controls around the delete anime feature.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDeleteAnimeSecurity:
|
||||
"""Security tests for the delete anime feature."""
|
||||
|
||||
@pytest.fixture
|
||||
def anime_service_code(self):
|
||||
"""Read the anime_service.py source code for security checks."""
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'services', 'anime_service.py'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
@pytest.fixture
|
||||
def delete_modal_code(self):
|
||||
"""Read the delete-modal.js source code for security checks."""
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
def test_delete_series_uses_is_safe_path(self, anime_service_code):
|
||||
"""delete_series uses is_safe_path before deleting folders."""
|
||||
assert 'is_safe_path' in anime_service_code
|
||||
assert 'folder_path' in anime_service_code
|
||||
|
||||
def test_delete_series_checks_anime_base_directory(self, anime_service_code):
|
||||
"""delete_series validates paths against the anime base directory."""
|
||||
# Should reference the anime directory for path comparison
|
||||
assert 'anime_base_dir' in anime_service_code or 'directory_to_search' in anime_service_code
|
||||
|
||||
def test_delete_series_no_hardcoded_paths(self, anime_service_code):
|
||||
"""delete_series has no hardcoded dangerous paths."""
|
||||
dangerous = ['/etc/passwd', '/root/.ssh', 'C:\\Windows\\System32']
|
||||
for path in dangerous:
|
||||
assert path not in anime_service_code
|
||||
|
||||
def test_delete_modal_encodes_key_in_url(self, delete_modal_code):
|
||||
"""delete-modal.js encodes the series key in the API URL."""
|
||||
# Should use encodeURIComponent or similar for the key
|
||||
assert 'encodeURIComponent' in delete_modal_code
|
||||
|
||||
def test_delete_modal_no_inner_html_with_user_data(self, delete_modal_code):
|
||||
"""delete-modal.js does not use innerHTML with unsanitized user data."""
|
||||
# innerHTML should not be used with direct variable interpolation
|
||||
# that could allow XSS
|
||||
lines = delete_modal_code.split('\n')
|
||||
dangerous_lines = [
|
||||
line for line in lines
|
||||
if 'innerHTML' in line and 'currentSeriesName' in line
|
||||
and 'escapeHtml' not in line
|
||||
]
|
||||
assert len(dangerous_lines) == 0, \
|
||||
"innerHTML used with currentSeriesName without escapeHtml"
|
||||
|
||||
def test_delete_modal_uses_textContent_for_user_visible_text(self, delete_modal_code):
|
||||
"""User-visible text in modal uses safe DOM methods."""
|
||||
# Should use textContent or similar instead of innerHTML for data
|
||||
# This is implicit in using template literals with ${} - but check no obvious XSS
|
||||
assert '<script>' not in delete_modal_code.lower()
|
||||
assert 'onclick=' not in delete_modal_code.lower()
|
||||
|
||||
def test_delete_modal_confirm_text_not_used_in_url(self, delete_modal_code):
|
||||
"""confirm_text is only used in JSON body, never in URLs."""
|
||||
lines = delete_modal_code.split('\n')
|
||||
for line in lines:
|
||||
if 'confirm_text' in line:
|
||||
# confirm_text should only appear in JSON body serialization
|
||||
assert 'URL' not in line and 'url' not in line or 'body' in line
|
||||
|
||||
def test_delete_modal_has_error_display_element(self, delete_modal_code):
|
||||
"""Modal has a dedicated error display element (not using alert())."""
|
||||
assert 'delete-error' in delete_modal_code
|
||||
assert 'showToast' in delete_modal_code # Uses toast, not alert()
|
||||
|
||||
def test_delete_endpoint_validates_confirm_text_exactly(self):
|
||||
"""The API validates confirm_text is exactly 'delete'."""
|
||||
# This is enforced in the endpoint code - check the endpoint exists
|
||||
from src.server.api.anime import router
|
||||
routes = [r for r in router.routes]
|
||||
assert len(routes) > 0 # Router has routes
|
||||
|
||||
def test_delete_modal_handles_401_without_data_leak(self, delete_modal_code):
|
||||
"""401 response triggers logout redirect, no data exposure."""
|
||||
assert 'removeToken' in delete_modal_code
|
||||
assert "window.location.href = '/login'" in delete_modal_code
|
||||
|
||||
def test_delete_modal_no_credentials_in_url(self, delete_modal_code):
|
||||
"""No credentials or tokens appear in the API URL."""
|
||||
lines = delete_modal_code.split('\n')
|
||||
for line in lines:
|
||||
if 'api/' in line.lower():
|
||||
# URL should not contain token/password
|
||||
assert 'token=' not in line.lower()
|
||||
assert 'password=' not in line.lower()
|
||||
|
||||
def test_delete_result_message_is_user_facing_only(self, delete_modal_code):
|
||||
"""Success/error messages shown to user do not expose internal paths."""
|
||||
# The toast should show only the message from the API, not raw folder paths
|
||||
assert 'showToast' in delete_modal_code
|
||||
|
||||
def test_delete_confirm_text_min_length_enforced(self):
|
||||
"""confirm_text field requires minimum length of 6 ('delete')."""
|
||||
from src.server.models.anime import DeleteSeriesRequest
|
||||
# The field uses a literal comparison, so exact match is enforced
|
||||
# Try constructing with wrong confirm_text
|
||||
import pytest as pt
|
||||
with pt.raises(Exception):
|
||||
DeleteSeriesRequest(
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
confirm_text="del" # Too short
|
||||
)
|
||||
|
||||
def test_delete_confirm_text_max_length_reasonable(self):
|
||||
"""confirm_text has a reasonable max length to prevent DoS."""
|
||||
from src.server.models.anime import DeleteSeriesRequest
|
||||
# 100 chars is reasonable - 10000 is not (tested in API tests)
|
||||
assert DeleteSeriesRequest(
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
confirm_text="x" * 100
|
||||
)
|
||||
# Confirm text must be exactly "delete" so this validates the check
|
||||
|
||||
def test_series_key_not_used_directly_in_shell_commands(self, anime_service_code):
|
||||
"""delete_series does not use series key in shell commands."""
|
||||
dangerous_patterns = ['os.system', 'subprocess.call', 'subprocess.run',
|
||||
'subprocess.Popen', 'eval(', 'exec(']
|
||||
for pattern in dangerous_patterns:
|
||||
assert pattern not in anime_service_code, \
|
||||
f"Potentially dangerous pattern '{pattern}' found"
|
||||
|
||||
def test_delete_modal_loads_before_context_menu_handler(self):
|
||||
"""delete-modal.js is loaded before app-init.js in the template."""
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'templates', 'index.html'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
delete_pos = content.find('delete-modal.js')
|
||||
app_init_pos = content.find('app-init.js')
|
||||
|
||||
assert delete_pos != -1, "delete-modal.js not found in template"
|
||||
assert app_init_pos != -1, "app-init.js not found in template"
|
||||
assert delete_pos < app_init_pos, \
|
||||
"delete-modal.js must be loaded before app-init.js"
|
||||
|
||||
def test_delete_modal_init_in_app_init(self, delete_modal_code):
|
||||
"""delete-modal.js is initialized in app-init.js."""
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'app-init.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'DeleteModal.init' in content
|
||||
|
||||
def test_context_menu_has_delete_action(self):
|
||||
"""context-menu.js includes the delete-anime action."""
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'context-menu.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'delete-anime' in content
|
||||
assert 'DeleteModal.show' in content
|
||||
519
tests/unit/test_delete_anime_service.py
Normal file
519
tests/unit/test_delete_anime_service.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""Unit tests for AnimeService.delete_series()."""
|
||||
from __future__ import annotations
|
||||
|
||||
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()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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",
|
||||
)
|
||||
Reference in New Issue
Block a user