Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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