Files
Aniworld/tests/frontend/test_delete_modal.py
Lukas ff526e08ea fix(delete-modal): close modal after successful delete
The success path of handleConfirm() never reset the isSubmitting flag,
so hide()'s early-return guard (line 209: 'if (isSubmitting) return')
kept the modal visible after a successful delete — leaving the user
looking at a stuck 'Deleting...' dialog while the card had already been
removed by the WebSocket SERIES_DELETED event.

Reset isSubmitting and the confirm button text before calling hide(),
and capture currentKey into a local before hide() nulls it so the
follow-up removeSeries() call receives the right key.
2026-09-04 18:56:47 +02:00

466 lines
16 KiB
Python

"""
Frontend unit tests for delete-modal.js.
Tests the DeleteModal JavaScript module logic in isolation.
Since this is a browser-only module, we test the underlying logic
(validation, URL construction, response handling) as Python logic.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Module-level mock classes (shared across tests)
class MockUI:
"""Mock UI module."""
showToast_called = []
@staticmethod
def showToast(msg, level):
MockUI.showToast_called.append((msg, level))
class MockResponse:
"""Simulates httpx AsyncClient response used by delete-modal.js."""
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
class MockApiClient:
"""Mock ApiClient that simulates delete-modal.js API calls."""
last_request = None
@classmethod
async def request(cls, url, options=None):
cls.last_request = (url, options)
# Route based on key in URL
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"})
# Store original for reset
_original_api_request = MockApiClient.request
class MockAniWorld:
"""Mock AniWorld namespace used by delete-modal.js."""
UI = MockUI
ApiClient = MockApiClient
DeleteModal = None
SeriesManager = None
Auth = MagicMock()
Auth.removeToken = MagicMock()
@pytest.fixture(autouse=True)
def reset_mock_aniworld():
"""Reset mock state before each test to prevent pollution."""
MockUI.showToast_called = []
MockApiClient.last_request = None
# Restore both MockApiClient.request AND MockAniWorld.ApiClient.request
# (tests may set either one directly)
MockApiClient.request = _original_api_request
MockAniWorld.ApiClient = MockApiClient
MockAniWorld.SeriesManager = None
MockAniWorld.Auth = MagicMock()
MockAniWorld.Auth.removeToken = MagicMock()
yield
class TestDeleteModalValidation:
"""Tests for the confirm-text validation logic."""
def test_confirm_input_disables_button_until_delete_typed(self):
"""Button is disabled until user types 'delete'."""
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):
"""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):
"""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):
"""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_exact_match_required(self):
"""confirmText must be exactly 'delete' (case-sensitive)."""
test_cases = [
("delete", True),
("DELETE", False),
("Delete", False),
(" 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}"
def test_delete_api_url_construction(self):
"""DELETE request is sent to /api/anime/{key}."""
key = "test-show-key"
url = '/api/anime/' + key
assert url == "/api/anime/test-show-key"
assert "test-show-key" in url
def test_delete_api_body_construction(self):
"""API body contains all three required fields."""
delete_database = True
delete_folder = False
confirm_text = "delete"
body = {
"delete_database": delete_database,
"delete_folder": delete_folder,
"confirm_text": confirm_text
}
assert body["delete_database"] is True
assert body["delete_folder"] is False
assert body["confirm_text"] == "delete"
class TestDeleteModalAPI:
"""Tests for the delete modal API interaction logic."""
@pytest.mark.asyncio
async def test_api_called_with_correct_url_and_method(self):
"""DELETE request is sent to correct endpoint."""
url = "/api/anime/test-show-key"
options = {
"method": "DELETE",
"headers": {"Content-Type": "application/json"},
"body": '{"delete_database": true, "delete_folder": false, "confirm_text": "delete"}'
}
response = await MockAniWorld.ApiClient.request(url, options)
assert response.status == 200
@pytest.mark.asyncio
async def test_api_returns_404_shows_not_found_error(self):
"""API 404 response returns 'not found' detail."""
response = await MockAniWorld.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_400_shows_validation_error(self):
"""API 400 response contains validation error detail."""
response = await MockAniWorld.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_raises_exception(self):
"""Network failure raises an exception."""
MockAniWorld.ApiClient.request = AsyncMock(
side_effect=Exception("Network connection failed")
)
with pytest.raises(Exception) as exc_info:
await MockAniWorld.ApiClient.request("/api/anime/test", {})
assert "network" in str(exc_info.value).lower() or "failed" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_success_response_contains_deleted_fields(self):
"""Successful response includes deleted_from_database and deleted_folder."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/test-show-key",
{"method": "DELETE"}
)
data = await response.json()
assert "success" in data
assert "deleted_from_database" in data
assert "deleted_folder" in data
class TestDeleteModalSeriesManagerIntegration:
"""Tests for SeriesManager.removeSeries integration."""
def test_remove_series_called_after_success(self):
"""After successful delete, removeSeries(key) is called."""
key = "test-show-key"
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate: after successful API response
result = {"success": True, "key": key, "name": "Test Show"}
if result["success"] and MockAniWorld.SeriesManager:
MockAniWorld.SeriesManager.removeSeries(result["key"])
assert remove_called_with == [key]
def test_remove_series_not_called_on_failure(self):
"""removeSeries is NOT called when API returns error."""
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate: API returns error
result = {"success": False, "key": "test-show-key", "message": "Not found"}
if result["success"] and MockAniWorld.SeriesManager:
MockAniWorld.SeriesManager.removeSeries(result["key"])
assert remove_called_with == []
def test_ws_event_broadcast_triggers_remove(self):
"""WebSocket series_deleted event triggers removeSeries."""
key = "ws-deleted-key"
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate WS event handler
def on_series_deleted(data):
if MockAniWorld.SeriesManager and MockAniWorld.SeriesManager.removeSeries:
MockAniWorld.SeriesManager.removeSeries(data["key"])
on_series_deleted({"key": key})
assert remove_called_with == [key]
class TestDeleteModalModalCloseAfterSuccess:
"""Regression tests for the bug where the modal stayed visible after a
successful delete because isSubmitting was never reset on the success path.
Source-level tests (the JS is not run in pytest): they assert that the
handleConfirm success branch (a) clears isSubmitting before hide(), and
(b) removes the card via a key captured before hide() nulls currentKey.
"""
@staticmethod
def _read_source():
import os
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_isSubmitting_reset_on_success_path(self):
"""isSubmitting must be reset to false after a successful delete,
otherwise hide()'s early-return guard keeps the modal visible."""
src = self._read_source()
# Locate the success branch: it begins with "Delete succeeded:" log
success_idx = src.find("Delete succeeded:")
assert success_idx > 0, "Could not find Delete succeeded log line"
# Everything between the success log and the catch block belongs to
# the success path.
catch_idx = src.find("} catch (err)", success_idx)
assert catch_idx > 0, "Could not find catch block after success path"
success_branch = src[success_idx:catch_idx]
# The flag must be reset in this branch...
assert "isSubmitting = false" in success_branch, (
"isSubmitting is never reset on the success path — "
"this is the bug that left the modal visible with 'Deleting...'"
)
# ...BEFORE hide() is called.
reset_pos = success_branch.find("isSubmitting = false")
hide_pos = success_branch.find("hide();")
assert reset_pos > 0 and hide_pos > 0, (
"Could not locate isSubmitting reset or hide() call"
)
assert reset_pos < hide_pos, (
"isSubmitting must be cleared BEFORE hide() — otherwise hide()'s "
"guard (`if (isSubmitting) return`) bails out and the modal stays"
)
def test_modal_hidden_class_added_on_success(self):
"""After the success-path cleanup, hide() must run and apply the
'hidden' class. We verify by checking hide() is reached after the
isSubmitting reset (covered above) and that the reset precedes the
removal of the card from the grid."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
reset_pos = success_branch.find("isSubmitting = false")
hide_pos = success_branch.find("hide();")
remove_pos = success_branch.find("removeSeries(")
assert 0 < reset_pos < hide_pos < remove_pos, (
"Order on success path must be: "
"isSubmitting reset -> hide() -> removeSeries()"
)
def test_removeSeries_uses_captured_key_not_live_currentKey(self):
"""hide() nulls currentKey on line ~211. If removeSeries reads the
live currentKey AFTER hide(), it gets null and silently no-ops. The
fix captures the key into a local before hide() runs."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
# There must be a local capture of the key before hide().
assert "var deletedKey = currentKey;" in success_branch, (
"Success path must capture currentKey into a local before "
"hide() nulls it — otherwise removeSeries(currentKey) would be "
"a silent no-op."
)
# The removeSeries call must reference the captured local, not
# currentKey directly.
capture_pos = success_branch.find("var deletedKey = currentKey;")
remove_pos = success_branch.find("removeSeries(deletedKey)")
assert capture_pos > 0 and remove_pos > 0, (
"removeSeries must be called with the captured deletedKey"
)
assert capture_pos < remove_pos, (
"Capture must happen BEFORE removeSeries reads it"
)
def test_confirm_button_text_reset_on_success(self):
"""The button text is changed to 'Deleting...' during submit and must
be reverted to 'Delete' so the modal is in a clean state if reopened."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
assert "confirmBtn.textContent = 'Delete'" in success_branch, (
"Confirm button text must be reset to 'Delete' on the success path"
)
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
assert 'removeSeries' in content