"""Tests for webhook notification system: retry logic and Slack/Discord payload format. Covers issue #1657: - Retry logic with exponential backoff in _send_with_retry - Slack/Discord payload formatting in _build_payload - Generic HTTP POST payload formatting - notify() dispatching to multiple URLs - notify_job_completed() and notify_alert() convenience helpers """ from datetime import datetime from unittest.mock import MagicMock, patch, call import pytest import requests from SPARC.webhooks import ( MAX_RETRIES, _build_payload, _is_slack_url, _send_with_retry, notify, notify_alert, notify_job_completed, ) class TestIsSlackUrl: """Tests for Slack/Discord URL detection.""" def test_slack_webhook_url(self): assert _is_slack_url("https://hooks.slack.com/services/T00/B00/xxx") is True def test_discord_webhook_url(self): assert _is_slack_url("https://discord.com/api/webhooks/123/abc") is True def test_generic_url(self): assert _is_slack_url("https://example.com/webhook") is False def test_empty_url(self): assert _is_slack_url("") is False class TestBuildPayload: """Tests for payload construction.""" def test_generic_payload_structure(self): """Generic payload includes event type, timestamp, and data.""" payload = _build_payload("job_completed", {"job_id": "abc123"}) assert payload["event"] == "job_completed" assert payload["job_id"] == "abc123" assert "timestamp" in payload # Timestamp should be ISO format ending with Z assert payload["timestamp"].endswith("Z") def test_slack_payload_wraps_in_text(self): """Slack payload wraps content in a 'text' field.""" payload = _build_payload("patent_alert", {"company_name": "NVIDIA"}, slack=True) assert "text" in payload assert "patent_alert" in payload["text"] assert "NVIDIA" in payload["text"] # Slack payload should NOT have the event/timestamp at top level assert "event" not in payload assert "timestamp" not in payload def test_generic_payload_does_not_have_text_field(self): """Non-Slack payload does not wrap in text.""" payload = _build_payload("job_completed", {"status": "done"}) assert "text" not in payload assert payload["status"] == "done" def test_slack_payload_contains_bold_header(self): """Slack payload starts with bold event header using Slack markdown.""" payload = _build_payload("job_completed", {"count": 5}, slack=True) assert payload["text"].startswith("*[SPARC] job_completed*") def test_payload_merges_all_data_keys(self): """All data keys are included in the generic payload.""" data = {"key1": "val1", "key2": 42, "key3": True} payload = _build_payload("test_event", data) assert payload["key1"] == "val1" assert payload["key2"] == 42 assert payload["key3"] is True class TestSendWithRetry: """Tests for retry logic in _send_with_retry.""" @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_success_on_first_attempt(self, mock_post, mock_sleep): """Successful delivery on first attempt, no retries.""" mock_post.return_value = MagicMock(status_code=200) result = _send_with_retry("https://example.com/hook", {"event": "test"}) assert result is True mock_post.assert_called_once() mock_sleep.assert_not_called() @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_success_on_second_attempt(self, mock_post, mock_sleep): """Fails first, succeeds on retry.""" mock_post.side_effect = [ MagicMock(status_code=500), MagicMock(status_code=200), ] result = _send_with_retry("https://example.com/hook", {"event": "test"}) assert result is True assert mock_post.call_count == 2 mock_sleep.assert_called_once() @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_all_retries_exhausted(self, mock_post, mock_sleep): """Returns False after all retries fail.""" mock_post.return_value = MagicMock(status_code=500) result = _send_with_retry("https://example.com/hook", {"event": "test"}) assert result is False assert mock_post.call_count == MAX_RETRIES assert mock_sleep.call_count == MAX_RETRIES - 1 @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_exponential_backoff_timing(self, mock_post, mock_sleep): """Backoff wait times follow exponential pattern (2^attempt).""" mock_post.return_value = MagicMock(status_code=500) _send_with_retry("https://example.com/hook", {"event": "test"}) # With BACKOFF_BASE=2: attempt 1 -> sleep(2), attempt 2 -> sleep(4) expected_waits = [call(2 ** i) for i in range(1, MAX_RETRIES)] assert mock_sleep.call_args_list == expected_waits @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_network_error_triggers_retry(self, mock_post, mock_sleep): """Network exceptions trigger retry, not immediate failure.""" mock_post.side_effect = [ requests.ConnectionError("Connection refused"), MagicMock(status_code=200), ] result = _send_with_retry("https://example.com/hook", {"event": "test"}) assert result is True assert mock_post.call_count == 2 @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_timeout_error_triggers_retry(self, mock_post, mock_sleep): """Timeout exceptions trigger retry.""" mock_post.side_effect = [ requests.Timeout("Request timed out"), MagicMock(status_code=200), ] result = _send_with_retry("https://example.com/hook", {"event": "test"}) assert result is True assert mock_post.call_count == 2 @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_2xx_status_codes_accepted(self, mock_post, mock_sleep): """Any 2xx status code is treated as success.""" mock_post.return_value = MagicMock(status_code=204) result = _send_with_retry("https://example.com/hook", {"event": "test"}) assert result is True mock_post.assert_called_once() @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_posts_json_payload(self, mock_post, mock_sleep): """Payload is sent as JSON with correct timeout.""" mock_post.return_value = MagicMock(status_code=200) payload = {"event": "test", "data": "value"} _send_with_retry("https://example.com/hook", payload) mock_post.assert_called_once_with( "https://example.com/hook", json=payload, timeout=10 ) class TestNotify: """Tests for the notify() dispatcher.""" @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", ["https://example.com/hook1", "https://example.com/hook2"]) def test_dispatches_to_all_urls(self, mock_send): """notify() sends to every configured webhook URL.""" mock_send.return_value = True notify("job_completed", {"job_id": "test123"}) assert mock_send.call_count == 2 @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", []) def test_no_urls_configured_returns_immediately(self, mock_send): """No-op when no webhook URLs are configured.""" notify("job_completed", {"job_id": "test123"}) mock_send.assert_not_called() @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", [ "https://hooks.slack.com/services/T00/B00/xxx", "https://example.com/generic", ]) def test_slack_url_gets_slack_payload(self, mock_send): """Slack URLs receive Slack-formatted payloads, others get generic.""" mock_send.return_value = True notify("test_event", {"key": "val"}) # First call (Slack URL) should have "text" key slack_payload = mock_send.call_args_list[0][0][1] assert "text" in slack_payload # Second call (generic URL) should have "event" key generic_payload = mock_send.call_args_list[1][0][1] assert "event" in generic_payload assert generic_payload["event"] == "test_event" class TestNotifyJobCompleted: """Tests for notify_job_completed() convenience function.""" @patch("SPARC.webhooks.notify") def test_sends_correct_event_and_data(self, mock_notify): """Job completion sends proper event type and summary.""" notify_job_completed( job_id="batch-001", status="completed", total_companies=10, successful=8, failed=2, ) mock_notify.assert_called_once() event, data = mock_notify.call_args[0] assert event == "job_completed" assert data["job_id"] == "batch-001" assert data["successful"] == 8 assert data["failed"] == 2 assert "8/10" in data["summary"] class TestNotifyAlert: """Tests for notify_alert() convenience function.""" @patch("SPARC.webhooks.notify") def test_sends_correct_event_and_data(self, mock_notify): """Alert notification sends patent_alert event type.""" notify_alert( company_name="NVIDIA", alert_type="patent_count_change", message="Patent count increased by 30%", ) mock_notify.assert_called_once() event, data = mock_notify.call_args[0] assert event == "patent_alert" assert data["company_name"] == "NVIDIA" assert data["alert_type"] == "patent_count_change" assert "30%" in data["message"]