"""Extra coverage for notifications/service.py — _send_with_retry, URL validation, service detection.""" from unittest.mock import MagicMock, patch import pytest from local_deep_research.notifications.service import ( NotificationService, SendError, ServiceError, ) MODULE = "local_deep_research.notifications.service" def _make_service(): """Create a NotificationService with mocked apprise.""" with patch(f"{MODULE}.apprise") as mock_apprise_module: mock_apprise_module.Apprise.return_value = MagicMock() svc = NotificationService.__new__(NotificationService) svc.apprise = mock_apprise_module.Apprise.return_value svc.allow_private_ips = False # Tests in this file exercise inner logic; open the gate so # send() doesn't bail at the operator-level check. svc.outbound_allowed = True svc.SERVICE_PATTERNS = NotificationService.SERVICE_PATTERNS return svc # =========================================================================== # _send_with_retry — direct invocation # =========================================================================== class TestSendWithRetry: def test_success_returns_true(self): svc = _make_service() mock_apprise = MagicMock() mock_apprise.notify.return_value = True # Call _send_with_retry directly (bypasses Tenacity decorator in test) result = svc._send_with_retry("Title", "Body", mock_apprise) assert result is True mock_apprise.notify.assert_called_once() def test_failure_raises_send_error(self): svc = _make_service() mock_apprise = MagicMock() mock_apprise.notify.return_value = False with pytest.raises(SendError, match="Failed to send"): svc._send_with_retry("Title", "Body", mock_apprise) def test_with_tag_and_attach(self): svc = _make_service() mock_apprise = MagicMock() mock_apprise.notify.return_value = True result = svc._send_with_retry( "Title", "Body", mock_apprise, tag="urgent", attach=["/tmp/f.txt"] ) assert result is True call_kwargs = mock_apprise.notify.call_args.kwargs assert call_kwargs["tag"] == "urgent" assert call_kwargs["attach"] == ["/tmp/f.txt"] # =========================================================================== # send — URL validation failure # =========================================================================== class TestSendUrlValidation: def test_invalid_service_url_raises(self): svc = _make_service() mock_validator = MagicMock() mock_validator.validate_multiple_urls.return_value = ( False, "SSRF blocked", ) with ( patch(f"{MODULE}.NotificationURLValidator", mock_validator), pytest.raises((ServiceError, SendError)), ): svc.send("Title", "Body", service_urls="file:///etc/passwd") def test_apprise_add_fails_returns_false(self): svc = _make_service() mock_validator = MagicMock() mock_validator.validate_multiple_urls.return_value = (True, None) mock_apprise_mod = MagicMock() temp_instance = MagicMock() temp_instance.add.return_value = False mock_apprise_mod.Apprise.return_value = temp_instance with ( patch(f"{MODULE}.NotificationURLValidator", mock_validator), patch(f"{MODULE}.apprise", mock_apprise_mod), ): result = svc.send("Title", "Body", service_urls="discord://w/t") assert result is False def test_no_services_configured_returns_false(self): svc = _make_service() svc.apprise.__len__ = MagicMock(return_value=0) result = svc.send("Title", "Body") assert result is False # =========================================================================== # _validate_url # =========================================================================== class TestValidateUrlStatic: def test_empty_raises(self): with pytest.raises(ServiceError, match="non-empty"): NotificationService._validate_url("") def test_none_raises(self): with pytest.raises(ServiceError, match="non-empty"): NotificationService._validate_url(None) def test_int_raises(self): with pytest.raises(ServiceError, match="non-empty"): NotificationService._validate_url(123) def test_no_scheme_raises(self): with pytest.raises(ServiceError, match="Invalid URL"): NotificationService._validate_url("no-scheme-here") def test_valid_discord_url(self): NotificationService._validate_url("discord://webhook_id/token") def test_valid_mailto_url(self): NotificationService._validate_url("mailto://user:pass@smtp.com") # =========================================================================== # get_service_type # =========================================================================== class TestGetServiceType: def _get_type(self, url): svc = _make_service() return svc.get_service_type(url) def test_discord(self): assert self._get_type("discord://id/token") == "discord" def test_slack(self): assert self._get_type("slack://token_a/token_b/token_c") == "slack" def test_email(self): assert self._get_type("mailto://user:pass@smtp.com") == "email" def test_telegram(self): assert self._get_type("tgram://bot_token/chat_id") == "telegram" def test_unknown(self): assert self._get_type("custom://something") == "unknown" def test_case_insensitive(self): # Patterns use re.IGNORECASE assert self._get_type("Discord://ID/TOKEN") == "discord"