"""Tests for the webhook background task queue. Covers: - Worker lifecycle (start / stop / idempotent start) - Tasks are processed asynchronously by the worker - Retry logic is preserved (executed inside the worker thread) - enqueue_notify / enqueue_job_completed / enqueue_alert non-blocking helpers - Integration: queued webhook task is eventually delivered (mocked HTTP) """ import threading import time from unittest.mock import MagicMock, call, patch import pytest from SPARC.task_queue import ( WebhookTask, drain, enqueue, queue_size, start_worker, stop_worker, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _worker_lifecycle(): """Start the worker before each test and stop it after.""" start_worker() yield stop_worker(timeout=3) # --------------------------------------------------------------------------- # Worker lifecycle # --------------------------------------------------------------------------- class TestWorkerLifecycle: def test_start_is_idempotent(self): """Calling start_worker() twice does not create a second thread.""" import SPARC.task_queue as tq first = tq._worker_thread start_worker() assert tq._worker_thread is first def test_stop_worker_gracefully(self): """stop_worker joins the thread cleanly.""" import SPARC.task_queue as tq assert tq._worker_thread is not None stop_worker(timeout=3) assert tq._worker_thread is None # --------------------------------------------------------------------------- # Task processing # --------------------------------------------------------------------------- class TestTaskProcessing: @patch("SPARC.webhooks._send_with_retry") def test_enqueued_task_is_delivered(self, mock_send): """A task put on the queue is eventually processed by the worker.""" mock_send.return_value = True task = WebhookTask(url="https://example.com/hook", payload={"event": "test"}) enqueue(task) drain(timeout=5) mock_send.assert_called_once_with("https://example.com/hook", {"event": "test"}) @patch("SPARC.webhooks._send_with_retry") def test_multiple_tasks_processed_in_order(self, mock_send): """Tasks are processed FIFO.""" mock_send.return_value = True for i in range(3): enqueue(WebhookTask(url=f"https://example.com/{i}", payload={"n": i})) drain(timeout=5) assert mock_send.call_count == 3 urls = [c[0][0] for c in mock_send.call_args_list] assert urls == [ "https://example.com/0", "https://example.com/1", "https://example.com/2", ] @patch("SPARC.webhooks._send_with_retry") def test_enqueue_returns_immediately(self, mock_send): """enqueue() does not block even if the worker is slow.""" event = threading.Event() def slow_send(url, payload): event.wait(timeout=5) return True mock_send.side_effect = slow_send start = time.monotonic() enqueue(WebhookTask(url="https://slow.example.com", payload={})) elapsed = time.monotonic() - start # enqueue should return in well under 1 second assert elapsed < 0.5 # Let the worker finish event.set() drain(timeout=5) @patch("SPARC.webhooks._send_with_retry", side_effect=RuntimeError("boom")) def test_worker_survives_unexpected_error(self, mock_send): """An unexpected exception in delivery does not kill the worker.""" enqueue(WebhookTask(url="https://example.com/bad", payload={})) drain(timeout=5) # Worker is still alive; enqueue another task mock_send.side_effect = None mock_send.return_value = True enqueue(WebhookTask(url="https://example.com/good", payload={"ok": True})) drain(timeout=5) assert mock_send.call_count == 2 # --------------------------------------------------------------------------- # Retry logic preserved in worker context # --------------------------------------------------------------------------- class TestRetryInWorker: @patch("SPARC.webhooks.time.sleep") @patch("SPARC.webhooks.requests.post") def test_retry_logic_runs_inside_worker(self, mock_post, mock_sleep): """The worker thread uses _send_with_retry, which retries on failure.""" mock_post.side_effect = [ MagicMock(status_code=500), MagicMock(status_code=200), ] enqueue(WebhookTask( url="https://example.com/retry", payload={"event": "test"}, )) drain(timeout=10) 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_in_worker(self, mock_post, mock_sleep): """Worker handles permanent failure gracefully.""" mock_post.return_value = MagicMock(status_code=500) enqueue(WebhookTask( url="https://example.com/fail", payload={"event": "test"}, )) drain(timeout=10) from SPARC.webhooks import MAX_RETRIES assert mock_post.call_count == MAX_RETRIES # --------------------------------------------------------------------------- # Integration: enqueue_notify and convenience helpers # --------------------------------------------------------------------------- class TestEnqueueHelpers: @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", ["https://example.com/hook"]) def test_enqueue_notify_delivers_via_worker(self, mock_send): """enqueue_notify puts a task on the queue and the worker delivers it.""" mock_send.return_value = True from SPARC.webhooks import enqueue_notify enqueue_notify("test_event", {"key": "val"}) drain(timeout=5) mock_send.assert_called_once() url, payload = mock_send.call_args[0] assert url == "https://example.com/hook" assert payload["event"] == "test_event" assert payload["key"] == "val" @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", ["https://example.com/hook"]) def test_enqueue_job_completed(self, mock_send): """enqueue_job_completed sends job completion data via the queue.""" mock_send.return_value = True from SPARC.webhooks import enqueue_job_completed enqueue_job_completed( job_id="job-1", status="completed", total_companies=5, successful=4, failed=1, ) drain(timeout=5) mock_send.assert_called_once() payload = mock_send.call_args[0][1] assert payload["event"] == "job_completed" assert payload["job_id"] == "job-1" assert payload["successful"] == 4 @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", ["https://example.com/hook"]) def test_enqueue_alert(self, mock_send): """enqueue_alert sends alert data via the queue.""" mock_send.return_value = True from SPARC.webhooks import enqueue_alert enqueue_alert( company_name="NVIDIA", alert_type="patent_count_change", message="Patent count increased by 30%", ) drain(timeout=5) mock_send.assert_called_once() payload = mock_send.call_args[0][1] assert payload["event"] == "patent_alert" assert payload["company_name"] == "NVIDIA" @patch("SPARC.webhooks._send_with_retry") @patch("SPARC.webhooks.WEBHOOK_URLS", []) def test_enqueue_notify_noop_when_no_urls(self, mock_send): """enqueue_notify is a no-op when WEBHOOK_URLS is empty.""" from SPARC.webhooks import enqueue_notify enqueue_notify("test_event", {"key": "val"}) drain(timeout=2) 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_enqueue_notify_slack_formatting(self, mock_send): """Slack URLs get Slack-formatted payloads even via the queue.""" mock_send.return_value = True from SPARC.webhooks import enqueue_notify enqueue_notify("test_event", {"key": "val"}) drain(timeout=5) assert mock_send.call_count == 2 slack_payload = mock_send.call_args_list[0][0][1] assert "text" in slack_payload generic_payload = mock_send.call_args_list[1][0][1] assert "event" in generic_payload