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
173 lines
5.7 KiB
Python
173 lines
5.7 KiB
Python
"""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"
|