diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..9950030 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,280 @@ +"""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"]