""" Tests for app/utils/notification.py Tests notification utilities and URL masking. """ from unittest.mock import MagicMock, patch import pytest _TEST_CREDENTIAL_URL = "https://user:password@example.com/notify" # noqa: S105 _TEST_QUERY_URL = "https://example.com/api?key=secret1&password=secret2&public=visible" # noqa: S105 @pytest.mark.unit class TestNotificationUrlMasking: """Test URL masking for security""" def test_mask_sensitive_url_basic_auth(self): """Test masking of basic auth URLs""" from app.utils.notification import _mask_sensitive_url url = _TEST_CREDENTIAL_URL masked = _mask_sensitive_url(url) # Password should be masked assert "password" not in masked assert "****" in masked assert "user" in masked assert "example.com" in masked def test_mask_sensitive_url_discord(self): """Test masking of Discord webhook URLs""" from app.utils.notification import _mask_sensitive_url url = "discord://webhook_id/webhook_token/channel_id" masked = _mask_sensitive_url(url) # Token should be masked assert "webhook_token" not in masked assert "****" in masked assert "discord://" in masked def test_mask_sensitive_url_telegram(self): """Test masking of Telegram URLs""" from app.utils.notification import _mask_sensitive_url url = "tgram://bot_token/chat_id" masked = _mask_sensitive_url(url) # Bot token should be masked assert "bot_token" not in masked or "****" in masked assert "tgram://" in masked def test_mask_sensitive_url_with_token_parameter(self): """Test masking of URLs with token query parameters""" from app.utils.notification import _mask_sensitive_url url = "https://example.com/notify?token=secret_token_123&other=value" masked = _mask_sensitive_url(url) # Token value should be masked assert "secret_token_123" not in masked assert "token=****" in masked or "****" in masked assert "other=value" in masked def test_mask_sensitive_url_with_api_key(self): """Test masking of URLs with api_key parameter""" from app.utils.notification import _mask_sensitive_url url = "https://example.com/api?api_key=my_api_key_here" masked = _mask_sensitive_url(url) # API key should be masked assert "my_api_key_here" not in masked assert "****" in masked def test_mask_sensitive_url_with_multiple_params(self): """Test masking with multiple sensitive parameters""" from app.utils.notification import _mask_sensitive_url url = _TEST_QUERY_URL masked = _mask_sensitive_url(url) # Sensitive params should be masked assert "secret1" not in masked assert "secret2" not in masked assert "****" in masked assert "public=visible" in masked def test_mask_sensitive_url_no_sensitive_data(self): """Test masking of URLs without sensitive data""" from app.utils.notification import _mask_sensitive_url url = "https://example.com/notify?id=123&name=test" masked = _mask_sensitive_url(url) # Should return similar URL (no masking needed) assert "example.com" in masked assert "id=123" in masked or "****" not in masked or "****" in masked def test_mask_sensitive_url_empty_string(self): """Test masking of empty string""" from app.utils.notification import _mask_sensitive_url url = "" masked = _mask_sensitive_url(url) assert masked == "" def test_mask_sensitive_url_various_formats(self): """Test masking with various URL formats""" from app.utils.notification import _mask_sensitive_url test_urls = [ "mailto://user:password@gmail.com", "slack://token@workspace", "https://api.example.com?secret=hidden", ] for url in test_urls: masked = _mask_sensitive_url(url) # All should return strings assert isinstance(masked, str) # Most should have masking applied assert len(masked) > 0 @pytest.mark.unit class TestAppriseInitialization: """Test Apprise initialization""" @patch("app.utils.notification.apprise.Apprise") @patch("app.utils.notification.settings") def test_init_apprise_with_configured_urls(self, mock_settings, mock_apprise_class): """Test Apprise initialization with configured URLs""" import app.utils.notification from app.utils.notification import init_apprise # Reset global app.utils.notification._apprise = None mock_settings.notification_urls = [ "https://example.com/notify1", "https://example.com/notify2", ] mock_apprise_instance = MagicMock() mock_apprise_class.return_value = mock_apprise_instance result = init_apprise() # Should create Apprise instance mock_apprise_class.assert_called_once() # Should add configured URLs assert mock_apprise_instance.add.call_count == 2 # Should return the instance assert result == mock_apprise_instance @patch("app.utils.notification.apprise.Apprise") @patch("app.utils.notification.settings") def test_init_apprise_no_urls_configured(self, mock_settings, mock_apprise_class): """Test Apprise initialization without configured URLs""" import app.utils.notification from app.utils.notification import init_apprise # Reset global app.utils.notification._apprise = None mock_settings.notification_urls = [] mock_apprise_instance = MagicMock() mock_apprise_class.return_value = mock_apprise_instance result = init_apprise() # Should still create Apprise instance mock_apprise_class.assert_called_once() # Should not add any URLs mock_apprise_instance.add.assert_not_called() # Should return the instance assert result == mock_apprise_instance @patch("app.utils.notification.apprise.Apprise") @patch("app.utils.notification.settings") def test_init_apprise_caches_instance(self, mock_settings, mock_apprise_class): """Test that Apprise instance is cached""" import app.utils.notification from app.utils.notification import init_apprise # Reset global app.utils.notification._apprise = None mock_settings.notification_urls = [] mock_apprise_instance = MagicMock() mock_apprise_class.return_value = mock_apprise_instance # First call result1 = init_apprise() # Second call result2 = init_apprise() # Should only create once (cached) mock_apprise_class.assert_called_once() # Both should return same instance assert result1 == result2 @patch("app.utils.notification.apprise.Apprise") @patch("app.utils.notification.settings") def test_init_apprise_handles_add_failure(self, mock_settings, mock_apprise_class): """Test handling when adding notification URL fails""" import app.utils.notification from app.utils.notification import init_apprise # Reset global app.utils.notification._apprise = None mock_settings.notification_urls = ["invalid://url"] mock_apprise_instance = MagicMock() mock_apprise_instance.add.side_effect = Exception("Invalid URL format") mock_apprise_class.return_value = mock_apprise_instance # Should not raise exception, just log error result = init_apprise() # Should still return instance assert result == mock_apprise_instance @pytest.mark.unit class TestSendNotification: """Test send_notification function""" @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_no_urls_configured(self, mock_settings, mock_init_apprise): """Test sending notification when no URLs are configured.""" from app.utils.notification import send_notification mock_settings.notification_urls = [] result = send_notification("Test", "Test message") assert result is False mock_init_apprise.assert_not_called() @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_success_type(self, mock_settings, mock_init_apprise): """Test notification with success type.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_apprise = MagicMock() mock_server = MagicMock() mock_server.notify.return_value = True mock_apprise.servers = [mock_server] mock_init_apprise.return_value = mock_apprise result = send_notification("Test", "Message", notification_type="success") assert result is True mock_server.notify.assert_called_once() # Check that SUCCESS type was used call_kwargs = mock_server.notify.call_args[1] assert "notify_type" in call_kwargs @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_warning_type(self, mock_settings, mock_init_apprise): """Test notification with warning type.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_apprise = MagicMock() mock_server = MagicMock() mock_server.notify.return_value = True mock_apprise.servers = [mock_server] mock_init_apprise.return_value = mock_apprise result = send_notification("Test", "Message", notification_type="warn") assert result is True @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_failure_type(self, mock_settings, mock_init_apprise): """Test notification with failure type.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_apprise = MagicMock() mock_server = MagicMock() mock_server.notify.return_value = True mock_apprise.servers = [mock_server] mock_init_apprise.return_value = mock_apprise result = send_notification("Test", "Message", notification_type="failed") assert result is True @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_no_servers(self, mock_settings, mock_init_apprise): """Test notification when no servers are available.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_apprise = MagicMock() mock_apprise.servers = [] mock_init_apprise.return_value = mock_apprise result = send_notification("Test", "Message") assert result is False @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_partial_success(self, mock_settings, mock_init_apprise): """Test notification with multiple servers, some failing.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_apprise = MagicMock() mock_server1 = MagicMock() mock_server1.notify.return_value = True mock_server2 = MagicMock() mock_server2.notify.return_value = False mock_apprise.servers = [mock_server1, mock_server2] mock_init_apprise.return_value = mock_apprise result = send_notification("Test", "Message") assert result is True # At least one succeeded @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_with_attachments(self, mock_settings, mock_init_apprise): """Test notification with file attachments.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_apprise = MagicMock() mock_server = MagicMock() mock_server.notify.return_value = True mock_apprise.servers = [mock_server] mock_init_apprise.return_value = mock_apprise result = send_notification("Test", "Message", attachments=["/path/to/file.pdf"]) assert result is True call_kwargs = mock_server.notify.call_args[1] assert call_kwargs["attach"] == ["/path/to/file.pdf"] @patch("app.utils.notification.init_apprise") @patch("app.utils.notification.settings") def test_send_notification_exception_handling(self, mock_settings, mock_init_apprise): """Test that exceptions are caught and logged.""" from app.utils.notification import send_notification mock_settings.notification_urls = ["https://example.com/notify"] mock_init_apprise.side_effect = Exception("Connection error") result = send_notification("Test", "Message") assert result is False @pytest.mark.unit class TestNotificationHelpers: """Test notification helper functions.""" @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_celery_failure_disabled(self, mock_settings, mock_send): """Test Celery failure notification when disabled.""" from app.utils.notification import notify_celery_failure mock_settings.notify_on_task_failure = False result = notify_celery_failure("test_task", "task-123", Exception("Error"), [], {}) assert result is False mock_send.assert_not_called() @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_celery_failure_enabled(self, mock_settings, mock_send): """Test Celery failure notification when enabled.""" from app.utils.notification import notify_celery_failure mock_settings.notify_on_task_failure = True mock_send.return_value = True result = notify_celery_failure("test_task", "task-123", Exception("Error"), [], {}) assert result is True mock_send.assert_called_once() call_args = mock_send.call_args[1] assert call_args["notification_type"] == "failure" @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_credential_failure_disabled(self, mock_settings, mock_send): """Test credential failure notification when disabled.""" from app.utils.notification import notify_credential_failure mock_settings.notify_on_credential_failure = False result = notify_credential_failure("Dropbox", "Invalid token") assert result is False mock_send.assert_not_called() @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_startup_disabled(self, mock_settings, mock_send): """Test startup notification when disabled.""" from app.utils.notification import notify_startup mock_settings.notify_on_startup = False result = notify_startup() assert result is False mock_send.assert_not_called() @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_shutdown_disabled(self, mock_settings, mock_send): """Test shutdown notification when disabled.""" from app.utils.notification import notify_shutdown mock_settings.notify_on_shutdown = False result = notify_shutdown() assert result is False mock_send.assert_not_called() @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_file_processed_disabled(self, mock_settings, mock_send): """Test file processed notification when disabled.""" from app.utils.notification import notify_file_processed mock_settings.notify_on_file_processed = False result = notify_file_processed("test.pdf", 1024000, {}, []) assert result is False mock_send.assert_not_called() @patch("app.utils.notification.send_notification") @patch("app.utils.notification.settings") def test_notify_file_processed_with_metadata(self, mock_settings, mock_send): """Test file processed notification with metadata.""" from app.utils.notification import notify_file_processed mock_settings.notify_on_file_processed = True mock_send.return_value = True metadata = {"document_type": "invoice", "tags": ["important", "finance"]} result = notify_file_processed("test.pdf", 2048000, metadata, ["dropbox", "s3"]) assert result is True mock_send.assert_called_once() # Check message contains expected info # Access keyword arguments instead of positional call_kwargs = mock_send.call_args.kwargs message = call_kwargs.get("message") or call_kwargs.get("body") or mock_send.call_args[0][1] # 2048000 bytes = 1.953125 MB, which displays as 1.95 MB assert "1.95 MB" in message or "2.00 MB" in message or "2048000" in message assert "invoice" in message assert "important, finance" in message assert "dropbox, s3" in message