"""Tests for scheduler polling logic.""" from datetime import datetime, timedelta from services.scheduler.app import ( DEFAULT_CADENCES, MAX_RETRY_COUNT, build_job_payload, compute_backoff, get_cadence_for_source, is_source_due, ) class TestGetCadenceForSource: def test_default_cadence_market_api(self): assert get_cadence_for_source("market_api", None) == 60 def test_default_cadence_news_api(self): assert get_cadence_for_source("news_api", {}) == 300 def test_default_cadence_unknown_type(self): assert get_cadence_for_source("unknown", None) == 600 def test_override_from_config(self): config = {"polling_interval_seconds": 120} assert get_cadence_for_source("market_api", config) == 120 def test_override_minimum_clamp(self): config = {"polling_interval_seconds": 5} assert get_cadence_for_source("market_api", config) == 10 def test_invalid_override_falls_back(self): config = {"polling_interval_seconds": "not_a_number"} assert get_cadence_for_source("news_api", config) == DEFAULT_CADENCES["news_api"] class TestComputeBackoff: def test_first_retry(self): assert compute_backoff(0) == 60 def test_second_retry(self): assert compute_backoff(1) == 120 def test_capped_at_max(self): assert compute_backoff(20) == 3600 class TestIsSourceDue: def _now(self): return datetime(2026, 4, 11, 12, 0, 0) def test_never_run_is_due(self): assert is_source_due("market_api", None, None, None, 0, None, self._now()) def test_completed_within_cadence_not_due(self): last = self._now() - timedelta(seconds=30) assert not is_source_due("market_api", None, last, "completed", 0, None, self._now()) def test_completed_past_cadence_is_due(self): last = self._now() - timedelta(seconds=120) assert is_source_due("market_api", None, last, "completed", 0, None, self._now()) def test_running_not_due(self): last = self._now() - timedelta(seconds=5) assert not is_source_due("market_api", None, last, "running", 0, None, self._now()) def test_failed_within_backoff_not_due(self): last = self._now() - timedelta(seconds=30) next_retry = self._now() + timedelta(seconds=30) assert not is_source_due("market_api", None, last, "failed", 1, next_retry, self._now()) def test_failed_past_backoff_is_due(self): last = self._now() - timedelta(seconds=120) next_retry = self._now() - timedelta(seconds=10) assert is_source_due("market_api", None, last, "failed", 1, next_retry, self._now()) def test_failed_max_retries_not_due(self): last = self._now() - timedelta(seconds=120) assert not is_source_due( "market_api", None, last, "failed", MAX_RETRY_COUNT, None, self._now() ) def test_custom_cadence_respected(self): config = {"polling_interval_seconds": 600} last = self._now() - timedelta(seconds=300) assert not is_source_due("market_api", config, last, "completed", 0, None, self._now()) last_old = self._now() - timedelta(seconds=700) assert is_source_due("market_api", config, last_old, "completed", 0, None, self._now()) class TestBuildJobPayload: def test_payload_structure(self): source = { "source_id": "sid-1", "company_id": "cid-1", "ticker": "AAPL", "legal_name": "Apple Inc.", "source_type": "news_api", "source_name": "NewsAPI", "config": {"endpoint": "/v2/everything"}, "credibility_score": 0.8, } now = datetime(2026, 4, 11, 12, 0, 0) job = build_job_payload(source, ["Apple", "iPhone"], now) assert job["source_id"] == "sid-1" assert job["company_id"] == "cid-1" assert job["ticker"] == "AAPL" assert job["legal_name"] == "Apple Inc." assert job["aliases"] == ["Apple", "iPhone"] assert job["source_type"] == "news_api" assert job["config"] == {"endpoint": "/v2/everything"} assert job["credibility_score"] == 0.8 assert job["scheduled_at"] == now.isoformat() def test_payload_null_config(self): source = { "source_id": "sid-2", "company_id": "cid-2", "ticker": "MSFT", "legal_name": "Microsoft Corp.", "source_type": "market_api", "source_name": "Polygon", "config": None, "credibility_score": None, } job = build_job_payload(source, [], datetime(2026, 4, 11, 12, 0, 0)) assert job["config"] == {} assert job["credibility_score"] == 0.5 assert job["aliases"] == []