7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""
|
|
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
|