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.
466 lines
16 KiB
Python
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
|