""" Tests for NotificationService with Tenacity retry logic. """ import pytest from unittest.mock import patch, MagicMock from local_deep_research.notifications.service import ( NotificationService, MAX_RETRY_ATTEMPTS, INITIAL_RETRY_DELAY, RETRY_BACKOFF_MULTIPLIER, ) from local_deep_research.notifications.templates import EventType from local_deep_research.notifications.exceptions import SendError class TestNotificationServiceInit: """Tests for NotificationService initialization.""" def test_init_creates_apprise_instance(self): """Test initialization creates Apprise instance.""" service = NotificationService(outbound_allowed=True) assert service.apprise is not None class TestSendOutboundGate: """Tests for the operator-level master switch enforced inside send(). Defense-in-depth: the gate is also checked in NotificationManager and NotificationService.test_service. Re-checking inside send() means a direct caller (now or in the future) cannot bypass it. See SECURITY.md 'Notification Webhook SSRF'. """ @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_returns_false_when_outbound_disallowed( self, mock_apprise_class ): """send() must bail out before constructing/calling Apprise.""" mock_apprise_instance = MagicMock() mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=False) result = service.send( title="t", body="b", service_urls="discord://webhook/token", ) assert result is False # Apprise should never be touched once the gate refuses. mock_apprise_instance.notify.assert_not_called() mock_apprise_instance.add.assert_not_called() @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_proceeds_when_outbound_allowed(self, mock_apprise_class): """Sanity check: gate open => normal send path runs.""" mock_apprise_instance = MagicMock() mock_apprise_instance.notify.return_value = True mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) result = service.send( title="t", body="b", service_urls="discord://webhook/token", ) assert result is True assert mock_apprise_instance.notify.call_count == 1 class TestSendWithTenacity: """Tests for send method with Tenacity retry logic.""" @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_success_first_attempt(self, mock_apprise_class): """Test successful notification on first attempt.""" mock_apprise_instance = MagicMock() mock_apprise_instance.notify.return_value = True mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) result = service.send( title="Test Title", body="Test Body", service_urls="discord://webhook/token", ) assert result is True assert mock_apprise_instance.notify.call_count == 1 @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_retries_on_failure_with_tenacity(self, mock_apprise_class): """Test Tenacity handles exponential backoff retry automatically.""" mock_apprise_instance = MagicMock() mock_apprise_instance.add.return_value = True # Fail twice, succeed on third attempt mock_apprise_instance.notify.side_effect = [False, False, True] mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) result = service.send( title="Test", body="Body", service_urls="discord://webhook/token", ) assert result is True # Tenacity should have retried 3 times total (1 initial + 2 retries) assert mock_apprise_instance.notify.call_count == 3 # Verify the Tenacity decorator was applied assert hasattr(service._send_with_retry, "__wrapped__") @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_raises_after_max_retries_with_tenacity( self, mock_apprise_class ): """Test Tenacity raises exception after max retry attempts.""" mock_apprise_instance = MagicMock() mock_apprise_instance.add.return_value = True # Always fail mock_apprise_instance.notify.return_value = False mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) with pytest.raises(SendError, match="Failed to send notification"): service.send( title="Test", body="Body", service_urls="discord://webhook/token", ) # Tenacity should have tried 3 times (MAX_RETRY_ATTEMPTS) assert mock_apprise_instance.notify.call_count == MAX_RETRY_ATTEMPTS @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_handles_exception_with_retry_with_tenacity( self, mock_apprise_class ): """Test Tenacity handles exception retries automatically.""" mock_apprise_instance = MagicMock() mock_apprise_instance.add.return_value = True # Raise exception twice, succeed on third attempt mock_apprise_instance.notify.side_effect = [ Exception("Network error"), Exception("Network error"), True, ] mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) result = service.send( title="Test", body="Body", service_urls="discord://webhook/token", ) assert result is True # Tenacity should have retried on exceptions and succeeded assert mock_apprise_instance.notify.call_count == 3 @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_with_no_service_urls(self, mock_apprise_class): """Test send returns False when configured instance empty.""" service = NotificationService(outbound_allowed=True) # Don't provide service_urls, use configured instance result = service.send( title="Test", body="Body", # No service_urls parameter ) assert result is False class TestTenacityConfiguration: """Tests for Tenacity retry configuration.""" def test_retry_constants_are_defined(self): """Test backward compatibility constants are still available.""" assert MAX_RETRY_ATTEMPTS == 3 assert INITIAL_RETRY_DELAY == 0.5 assert RETRY_BACKOFF_MULTIPLIER == 2 @patch("local_deep_research.notifications.service.apprise.Apprise") def test_tenacity_retry_configuration(self, mock_apprise_class): """Test Tenacity is configured with correct retry parameters.""" mock_apprise_instance = MagicMock() mock_apprise_instance.add.return_value = True mock_apprise_instance.notify.return_value = False # Always fail mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) # Verify the retry decorator is applied assert hasattr(service._send_with_retry, "__wrapped__") # Check that the retry configuration matches our constants # (We can't easily inspect the decorator config, so we verify behavior) with pytest.raises(SendError): service.send( title="Test", body="Body", service_urls="discord://webhook/token", ) # Should have tried exactly MAX_RETRY_ATTEMPTS times assert mock_apprise_instance.notify.call_count == MAX_RETRY_ATTEMPTS class TestSendEvent: """Tests for send_event method.""" @patch("local_deep_research.notifications.service.apprise.Apprise") def test_send_event_formats_template(self, mock_apprise_class): """Test send_event formats message using template.""" mock_apprise_instance = MagicMock() mock_apprise_instance.notify.return_value = True mock_apprise_instance.add.return_value = True mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) # Include all required template variables context = { "query": "What is quantum computing?", "research_id": "123", "summary": "Brief summary", "url": "http://localhost:5000/research/123", } result = service.send_event( event_type=EventType.RESEARCH_COMPLETED, context=context, service_urls="discord://webhook/token", ) assert result is True # Verify notify was called with formatted message call_kwargs = mock_apprise_instance.notify.call_args[1] assert "title" in call_kwargs assert "body" in call_kwargs # Title should contain the query (from template: "Research Completed: {query}") assert "quantum computing" in call_kwargs["title"].lower() assert "research completed" in call_kwargs["title"].lower() class TestTestService: """Tests for test_service method.""" @patch("local_deep_research.notifications.service.apprise.Apprise") def test_test_service_success(self, mock_apprise_class): """Test successful service test.""" mock_apprise_instance = MagicMock() mock_apprise_instance.add.return_value = True mock_apprise_instance.notify.return_value = True mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) result = service.test_service("discord://webhook/token") assert result["success"] is True assert "message" in result # Verify test notification was sent mock_apprise_instance.notify.assert_called_once() def test_test_service_exception(self): """Test service test handles exceptions.""" service = NotificationService(outbound_allowed=True) # Mock the internal apprise.Apprise to raise exception with patch( "local_deep_research.notifications.service.apprise.Apprise" ) as mock_apprise: mock_instance = MagicMock() mock_instance.add.side_effect = Exception("Network error") mock_apprise.return_value = mock_instance result = service.test_service("discord://webhook/token") assert result["success"] is False assert "error" in result # Exception should be caught and returned in error field assert len(result["error"]) > 0 class TestGetServiceType: """Tests for get_service_type method.""" def test_get_service_type_discord(self): """Test detecting Discord service.""" service = NotificationService(outbound_allowed=True) service_type = service.get_service_type("discord://webhook/token") assert service_type == "discord" def test_get_service_type_unknown(self): """Test unknown service type.""" service = NotificationService(outbound_allowed=True) service_type = service.get_service_type("unknown://service") assert service_type == "unknown" class TestIntegration: """Integration tests for NotificationService.""" @patch("local_deep_research.notifications.service.apprise.Apprise") def test_complete_notification_flow(self, mock_apprise_class): """Test complete notification flow from event to send.""" mock_apprise_instance = MagicMock() mock_apprise_instance.add.return_value = True mock_apprise_instance.notify.return_value = True mock_apprise_class.return_value = mock_apprise_instance service = NotificationService(outbound_allowed=True) context = { "query": "Test research query", "research_id": "test-123", "summary": "Test summary", "url": "http://localhost:5000/research/test-123", } result = service.send_event( event_type=EventType.RESEARCH_COMPLETED, context=context, service_urls="discord://webhook/token", ) assert result is True mock_apprise_instance.notify.assert_called_once() # Verify the formatted message call_args = mock_apprise_instance.notify.call_args title = call_args[1]["title"] body = call_args[1]["body"] assert "Test research query" in title assert "Test summary" in body assert "http://localhost:5000/research/test-123" in body