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