""" 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 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