"""Extra coverage tests for notifications — service, manager, and url_builder gaps.""" from collections import deque from datetime import datetime, timedelta, UTC from unittest.mock import MagicMock import pytest from local_deep_research.notifications.manager import ( NotificationManager, RateLimiter, ) from local_deep_research.notifications.templates import EventType from local_deep_research.security.url_builder import ( build_base_url_from_settings, build_full_url, mask_sensitive_url, normalize_bind_address, validate_constructed_url, URLBuilderError, ) SVC_MODULE = "local_deep_research.notifications.service" MGR_MODULE = "local_deep_research.notifications.manager" # =========================================================================== # NotificationManager._should_notify exception path # =========================================================================== class TestShouldNotifyException: def test_exception_returns_false(self): """_should_notify returns False on exception to avoid loops.""" mgr = MagicMock(spec=NotificationManager) mgr._get_setting = MagicMock(side_effect=RuntimeError("db fail")) # Call the real method result = NotificationManager._should_notify( mgr, EventType.RESEARCH_COMPLETED ) assert result is False def test_enabled_returns_true(self): mgr = MagicMock(spec=NotificationManager) mgr._get_setting = MagicMock(return_value=True) result = NotificationManager._should_notify( mgr, EventType.RESEARCH_COMPLETED ) assert result is True def test_disabled_returns_false(self): mgr = MagicMock(spec=NotificationManager) mgr._get_setting = MagicMock(return_value=False) result = NotificationManager._should_notify( mgr, EventType.RESEARCH_COMPLETED ) assert result is False # =========================================================================== # NotificationManager._log_notification exception path # =========================================================================== class TestLogNotificationException: def test_exception_swallowed(self): """_log_notification swallows exceptions gracefully.""" mgr = MagicMock(spec=NotificationManager) mgr._user_id = "testuser" # context.get raising should be caught bad_context = MagicMock() bad_context.get.side_effect = RuntimeError("context error") NotificationManager._log_notification( mgr, EventType.RESEARCH_COMPLETED, bad_context ) def test_logs_query_from_context(self): mgr = MagicMock(spec=NotificationManager) mgr._user_id = "testuser" NotificationManager._log_notification( mgr, EventType.RESEARCH_COMPLETED, {"query": "test query"}, ) # =========================================================================== # RateLimiter._clean_old_entries # =========================================================================== class TestRateLimiterCleanOldEntries: def test_cleans_hourly_entries(self): limiter = RateLimiter(max_per_hour=10, max_per_day=100) user = "testuser" now = datetime.now(UTC) old = now - timedelta(hours=2) limiter._hourly_counts[user] = deque([old, old, now]) limiter._daily_counts[user] = deque([now]) limiter._clean_old_entries(user, now) assert len(limiter._hourly_counts[user]) == 1 def test_cleans_daily_entries(self): limiter = RateLimiter(max_per_hour=10, max_per_day=100) user = "testuser" now = datetime.now(UTC) old = now - timedelta(days=2) limiter._hourly_counts[user] = deque([now]) limiter._daily_counts[user] = deque([old, old, now]) limiter._clean_old_entries(user, now) assert len(limiter._daily_counts[user]) == 1 def test_cleans_all_old_entries(self): limiter = RateLimiter(max_per_hour=10, max_per_day=100) user = "testuser" now = datetime.now(UTC) old_h = now - timedelta(hours=2) old_d = now - timedelta(days=2) limiter._hourly_counts[user] = deque([old_h]) limiter._daily_counts[user] = deque([old_d]) limiter._clean_old_entries(user, now) assert len(limiter._hourly_counts[user]) == 0 assert len(limiter._daily_counts[user]) == 0 # =========================================================================== # RateLimiter._cleanup_inactive_users_if_needed # =========================================================================== class TestRateLimiterCleanupInactiveUsers: def test_skips_if_not_due(self): limiter = RateLimiter(max_per_hour=10, max_per_day=100) now = datetime.now(UTC) limiter._last_cleanup = now # just cleaned up limiter._hourly_counts["old_user"] = deque() limiter._cleanup_inactive_users_if_needed(now) # Should not have cleaned up (too soon) assert "old_user" in limiter._hourly_counts def test_removes_inactive_users(self): limiter = RateLimiter( max_per_hour=10, max_per_day=100, cleanup_interval_hours=1 ) now = datetime.now(UTC) # Force cleanup to be due limiter._last_cleanup = now - timedelta(hours=2) # Old inactive user old_time = now - timedelta(days=8) limiter._hourly_counts["inactive"] = deque([old_time]) limiter._daily_counts["inactive"] = deque([old_time]) # Recent active user limiter._hourly_counts["active"] = deque([now]) limiter._daily_counts["active"] = deque([now]) limiter._cleanup_inactive_users_if_needed(now) assert "inactive" not in limiter._hourly_counts assert "active" in limiter._hourly_counts def test_empty_queues_treated_as_inactive(self): limiter = RateLimiter( max_per_hour=10, max_per_day=100, cleanup_interval_hours=1 ) now = datetime.now(UTC) limiter._last_cleanup = now - timedelta(hours=2) limiter._hourly_counts["empty_user"] = deque() limiter._daily_counts["empty_user"] = deque() limiter._cleanup_inactive_users_if_needed(now) assert "empty_user" not in limiter._hourly_counts # =========================================================================== # RateLimiter.reset # =========================================================================== class TestRateLimiterReset: def test_reset_specific_user(self): limiter = RateLimiter(max_per_hour=10, max_per_day=100) limiter._hourly_counts["user1"] = deque([datetime.now(UTC)]) limiter._hourly_counts["user2"] = deque([datetime.now(UTC)]) limiter.reset("user1") assert "user1" not in limiter._hourly_counts assert "user2" in limiter._hourly_counts def test_reset_all_users(self): limiter = RateLimiter(max_per_hour=10, max_per_day=100) limiter._hourly_counts["user1"] = deque([datetime.now(UTC)]) limiter._hourly_counts["user2"] = deque([datetime.now(UTC)]) limiter.reset() assert len(limiter._hourly_counts) == 0 assert len(limiter._daily_counts) == 0 # =========================================================================== # NotificationService._validate_url # =========================================================================== class TestValidateUrl: def test_empty_url_raises(self): from local_deep_research.notifications.service import ( NotificationService, ServiceError, ) with pytest.raises(ServiceError, match="non-empty"): NotificationService._validate_url("") def test_none_url_raises(self): from local_deep_research.notifications.service import ( NotificationService, ServiceError, ) with pytest.raises(ServiceError, match="non-empty"): NotificationService._validate_url(None) def test_no_scheme_raises(self): from local_deep_research.notifications.service import ( NotificationService, ServiceError, ) with pytest.raises(ServiceError, match="Invalid URL"): NotificationService._validate_url("no-scheme-here") def test_valid_url_passes(self): from local_deep_research.notifications.service import ( NotificationService, ) NotificationService._validate_url("discord://webhook/token") # =========================================================================== # NotificationService.get_service_type # =========================================================================== class TestGetServiceType: def test_discord_detected(self): from local_deep_research.notifications.service import ( NotificationService, ) svc = MagicMock(spec=NotificationService) svc.SERVICE_PATTERNS = NotificationService.SERVICE_PATTERNS result = NotificationService.get_service_type( svc, "discord://webhook_id/webhook_token" ) assert result == "discord" def test_unknown_url(self): from local_deep_research.notifications.service import ( NotificationService, ) svc = MagicMock(spec=NotificationService) svc.SERVICE_PATTERNS = NotificationService.SERVICE_PATTERNS result = NotificationService.get_service_type(svc, "custom://something") assert result == "unknown" # =========================================================================== # url_builder — mask_sensitive_url # =========================================================================== class TestMaskSensitiveUrl: def test_masks_password(self): result = mask_sensitive_url("https://user:secret123@host.com/path") assert "secret123" not in result assert "***" in result def test_masks_long_path_tokens(self): result = mask_sensitive_url( "https://discord.com/api/webhooks/abcdefghijklmnopqrstuvwxyz123456" ) assert "/***" in result def test_masks_query_params(self): result = mask_sensitive_url( "https://host.com/path?token=secret&key=api" ) assert "?***" in result def test_no_password_no_mask(self): result = mask_sensitive_url("https://host.com/short") assert "***" not in result or result == "https://host.com/short" def test_invalid_url_returns_scheme_mask(self): result = mask_sensitive_url("badscheme://something") assert isinstance(result, str) def test_empty_path(self): result = mask_sensitive_url("https://host.com") assert "host.com" in result # =========================================================================== # url_builder — build_base_url_from_settings # =========================================================================== class TestBuildBaseUrlFromSettings: def test_external_url_priority(self): result = build_base_url_from_settings( external_url="https://myapp.com", host="localhost", port=5000, ) assert result == "https://myapp.com" def test_external_url_strips_trailing_slash(self): result = build_base_url_from_settings(external_url="https://myapp.com/") assert result == "https://myapp.com" def test_host_port_construction(self): result = build_base_url_from_settings(host="myhost", port=8080) assert result == "http://myhost:8080" def test_bind_address_normalized(self): result = build_base_url_from_settings(host="0.0.0.0", port=5000) assert result == "http://localhost:5000" def test_fallback(self): result = build_base_url_from_settings( fallback_base="http://custom:9999" ) assert result == "http://custom:9999" def test_empty_external_url_ignored(self): result = build_base_url_from_settings( external_url=" ", host="myhost", port=3000, ) assert result == "http://myhost:3000" # =========================================================================== # url_builder — validate_constructed_url # =========================================================================== class TestValidateConstructedUrl: def test_valid_url(self): assert validate_constructed_url("https://example.com/path") is True def test_empty_url_raises(self): with pytest.raises(URLBuilderError, match="non-empty"): validate_constructed_url("") def test_none_raises(self): with pytest.raises(URLBuilderError, match="non-empty"): validate_constructed_url(None) def test_no_scheme_raises(self): with pytest.raises(URLBuilderError, match="scheme"): validate_constructed_url("example.com/path") def test_disallowed_scheme_raises(self): with pytest.raises(URLBuilderError, match="not in allowed"): validate_constructed_url( "ftp://example.com", allowed_schemes=["http", "https"] ) def test_no_hostname_raises(self): with pytest.raises(URLBuilderError, match="hostname"): validate_constructed_url("http://") # =========================================================================== # url_builder — build_full_url # =========================================================================== class TestBuildFullUrl: def test_basic(self): result = build_full_url("https://myapp.com", "/research/123") assert result == "https://myapp.com/research/123" def test_adds_leading_slash(self): result = build_full_url( "https://myapp.com", "research/123", validate=False ) assert result == "https://myapp.com/research/123" def test_strips_trailing_slash_from_base(self): result = build_full_url("https://myapp.com/", "/path", validate=False) assert result == "https://myapp.com/path" def test_validation_enabled(self): result = build_full_url("https://myapp.com", "/path") assert result == "https://myapp.com/path" def test_validation_fails_raises(self): with pytest.raises(URLBuilderError): build_full_url("", "/path") # =========================================================================== # url_builder — normalize_bind_address # =========================================================================== class TestNormalizeBindAddress: def test_bind_all_ipv4(self): assert normalize_bind_address("0.0.0.0") == "localhost" def test_bind_all_ipv6(self): assert normalize_bind_address("::") == "localhost" def test_normal_host_unchanged(self): assert normalize_bind_address("myhost") == "myhost" def test_localhost_unchanged(self): assert normalize_bind_address("localhost") == "localhost"