added delete option
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user