""" 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 = ( '
' '' ) 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