added delete option

This commit is contained in:
2026-08-16 19:53:38 +02:00
parent 46e8b2c9eb
commit a7ed2c999c
22 changed files with 3265 additions and 3 deletions

View 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)

View 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

View 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

View 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",
)