""" Tests for src/common/api_helper.py (APIHelper). Covers rate limiting, cached GETs, ESPN URL/cache-key construction, session header defaults and per-call merging, the retry adapter, and the fixed clear_cache() behavior (real CacheManager surface: clear_cache / delete / list_cache_files, with safe no-ops elsewhere). No real network: helper.session.get/post are always replaced with mocks. """ import types from unittest.mock import MagicMock, Mock import pytest import requests from freezegun import freeze_time import src.common.api_helper as api_helper_module from src.common.espn_dates import ESPN_MAX_LIMIT from src.common.api_helper import APIHelper def _make_response(payload): response = MagicMock() response.json.return_value = payload response.raise_for_status.return_value = None return response @pytest.fixture def cache(): cache = MagicMock() cache.get.return_value = None return cache @pytest.fixture def helper(cache): helper = APIHelper(cache_manager=cache) # Default min interval is 1.0s and would really sleep between requests. helper.set_rate_limit(0) return helper # --------------------------------------------------------------------------- # Rate limiting # --------------------------------------------------------------------------- class TestRateLimiting: def test_sleeps_for_remaining_interval(self, helper, monkeypatch): fake_time = MagicMock() fake_time.monotonic.side_effect = [102.0, 105.0] fake_time.time.return_value = 5000.0 monkeypatch.setattr(api_helper_module, 'time', fake_time) helper.set_rate_limit(5) helper._last_request_monotonic = 100.0 helper._enforce_rate_limit() # 2s elapsed of a 5s interval -> sleep the remaining 3s. fake_time.sleep.assert_called_once() assert fake_time.sleep.call_args[0][0] == pytest.approx(3.0) assert helper._last_request_monotonic == 105.0 assert helper._last_request_time == 5000.0 def test_no_sleep_when_interval_elapsed(self, helper, monkeypatch): fake_time = MagicMock() fake_time.monotonic.side_effect = [200.0, 201.0] monkeypatch.setattr(api_helper_module, 'time', fake_time) helper.set_rate_limit(5) helper._last_request_monotonic = 100.0 helper._enforce_rate_limit() fake_time.sleep.assert_not_called() assert helper._last_request_monotonic == 201.0 def test_wall_clock_step_back_does_not_stall_requests(self, helper, monkeypatch): # NTP stepping the wall clock back an hour between two requests must # not turn into an hour-long "remaining interval" sleep. wall = {"now": 10_000.0} mono = {"now": 50.0} sleeps = [] fake_time = MagicMock() fake_time.time.side_effect = lambda: wall["now"] fake_time.monotonic.side_effect = lambda: mono["now"] fake_time.sleep.side_effect = sleeps.append monkeypatch.setattr(api_helper_module, 'time', fake_time) helper.set_rate_limit(5) helper._enforce_rate_limit() wall["now"] -= 3600 mono["now"] += 10 helper._enforce_rate_limit() assert all(s <= 5 for s in sleeps) # --------------------------------------------------------------------------- # get() # --------------------------------------------------------------------------- class TestGet: def test_cache_hit_skips_request_and_rate_limit(self, helper, cache): cache.get.return_value = {'cached': True} helper.session.get = Mock() rate_spy = Mock() helper._enforce_rate_limit = rate_spy result = helper.get('https://example.com/api', cache_key='k') assert result == {'cached': True} helper.session.get.assert_not_called() rate_spy.assert_not_called() def test_cache_miss_fetches_and_caches_with_ttl(self, helper, cache): cache.get.return_value = None helper.session.get = Mock(return_value=_make_response({'a': 1})) result = helper.get('https://example.com/api', cache_key='k', cache_ttl=999) assert result == {'a': 1} cache.set.assert_called_once_with('k', {'a': 1}, ttl=999) def test_set_cache_passes_ttl(self, helper, cache): helper.set_cache('k', {'a': 1}, ttl=42) cache.set.assert_called_once_with('k', {'a': 1}, ttl=42) class TestCacheLifetimeWithRealCacheManager: """cache_ttl decides how long a response is reused, in both directions: past CacheManager's 300-second default read age, and not beyond it.""" @pytest.fixture def real_cache(self, tmp_path): from unittest.mock import patch from src.cache_manager import CacheManager with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)): cache = CacheManager() yield cache # Releases the class-wide cleanup-thread claim on this directory, # which would otherwise leak into test_cache_cleanup_thread_ownership. cache.stop_cleanup_thread() def _fetch_twice(self, real_cache, monkeypatch, ttl, elapsed): helper = APIHelper(cache_manager=real_cache) helper.set_rate_limit(0) helper.session.get = Mock(side_effect=[_make_response({'n': 1}), _make_response({'n': 2})]) now = [1_000_000.0] monkeypatch.setattr('src.cache.memory_cache.time.time', lambda: now[0]) monkeypatch.setattr('src.cache.disk_cache.time.time', lambda: now[0]) monkeypatch.setattr('src.cache_manager.time.time', lambda: now[0]) helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl) now[0] += elapsed return helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl) def test_long_ttl_outlives_the_default_read_age(self, real_cache, monkeypatch): assert self._fetch_twice(real_cache, monkeypatch, ttl=3600, elapsed=1000) == {'n': 1} def test_short_ttl_expires(self, real_cache, monkeypatch): assert self._fetch_twice(real_cache, monkeypatch, ttl=60, elapsed=120) == {'n': 2} def test_request_exception_returns_none_and_caches_nothing( self, helper, cache): helper.session.get = Mock( side_effect=requests.exceptions.RequestException('boom')) result = helper.get('https://example.com/api', cache_key='k') assert result is None cache.set.assert_not_called() def test_timeout_zero_falls_back_to_default(self, helper): # Quirk pin: `timeout or self.default_timeout` treats an explicit # timeout=0 as falsy, so the default (30) is used instead. helper.session.get = Mock(return_value=_make_response({})) helper.get('https://example.com/api', timeout=0) assert helper.session.get.call_args.kwargs['timeout'] == 30 def test_per_call_headers_merge_over_session_headers(self, helper): helper.session.get = Mock(return_value=_make_response({})) helper.get('https://example.com/api', headers={'X-Custom': 'yes'}) sent = helper.session.get.call_args.kwargs['headers'] # Merged, not replaced: session defaults survive alongside the # per-call header. assert sent['X-Custom'] == 'yes' assert sent['User-Agent'] == ( 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)') assert sent['Accept'] == 'application/json' # The session's own headers are not polluted by the per-call ones. assert 'X-Custom' not in helper.session.headers # --------------------------------------------------------------------------- # ESPN helpers # --------------------------------------------------------------------------- class TestEspnHelpers: @freeze_time('2026-08-07') def test_fetch_espn_scoreboard_url_params_and_cache_key(self, helper, cache): # The canonical key (fetch service stage 2), shared with every other # consumer of the scoreboard; the old key is only read as a fallback. cache.get_cached_data.return_value = None helper._get = Mock(return_value={'ok': 1}) result = helper.fetch_espn_scoreboard('football', 'nfl') assert result == {'ok': 1} helper._get.assert_called_once_with( 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard', {'dates': '20260807', 'limit': ESPN_MAX_LIMIT}, None, None, None, 300, 300, ) read = [c.args[0] for c in cache.get_cached_data.call_args_list] assert read == ['espn_scoreboard_football_nfl_20260807', 'espn_football_nfl_20260807'] cache.set.assert_called_once_with('espn_scoreboard_football_nfl_20260807', {'ok': 1}) def test_fetch_espn_scoreboard_explicit_date(self, helper, cache): cache.get_cached_data.return_value = None helper._get = Mock(return_value=None) helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') args = helper._get.call_args.args assert args[1] == {'dates': '20250115', 'limit': ESPN_MAX_LIMIT} cache.set.assert_not_called() # a failed fetch caches nothing def test_fetch_espn_scoreboard_an_explicit_key_works_as_before(self, helper): helper.get = Mock(return_value={'ok': 1}) helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115', cache_key='mine') assert helper.get.call_args.kwargs['cache_key'] == 'mine' def test_fetch_espn_scoreboard_reads_the_old_key_after_an_upgrade(self, helper, cache): import time as _time records = {'espn_basketball_nba_20250115': { 'timestamp': _time.time() - 10, 'ttl': 300, 'data': {'events': ['old']}}} cache.get_cached_data.side_effect = lambda key, **kw: records.get(key) helper._get = Mock() assert helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') == { 'events': ['old']} helper._get.assert_not_called() def test_fetch_espn_standings_url_and_cache_key(self, helper): helper.get = Mock(return_value={'ok': 1}) helper.fetch_espn_standings('football', 'nfl') helper.get.assert_called_once_with( 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings', cache_key='espn_standings_football_nfl', cache_ttl=3600, ) def test_fetch_espn_rankings_url_and_cache_key(self, helper): helper.get = Mock(return_value={'ok': 1}) helper.fetch_espn_rankings('football', 'college-football') helper.get.assert_called_once_with( 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings', cache_key='espn_rankings_football_college-football', cache_ttl=3600, ) # --------------------------------------------------------------------------- # Session setup # --------------------------------------------------------------------------- class TestSessionSetup: def test_user_agent_exact(self, helper): # Regression guard: ESPN began 403ing other user agents; this exact # string must be sent on every request. assert helper.session.headers['User-Agent'] == ( 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)') def test_retry_adapter_configuration(self): helper = APIHelper(cache_manager=None, max_retries=7) retries = helper.session.get_adapter('https://x').max_retries assert retries.total == 7 assert {429, 500, 502, 503, 504} <= set(retries.status_forcelist) # --------------------------------------------------------------------------- # clear_cache (fixed behavior: real CacheManager surface) # --------------------------------------------------------------------------- class TestClearCache: def test_no_pattern_uses_clear_cache_method(self): manager = types.SimpleNamespace(clear_cache=Mock()) helper = APIHelper(cache_manager=manager) helper.set_rate_limit(0) helper.clear_cache() manager.clear_cache.assert_called_once_with() def test_no_pattern_manager_without_any_clear_is_noop(self): helper = APIHelper(cache_manager=object()) helper.set_rate_limit(0) helper.clear_cache() # must not raise def test_pattern_deletes_only_matching_keys(self): manager = types.SimpleNamespace( list_cache_files=Mock(return_value=[ {'key': 'espn_nfl_x'}, {'key': 'other'}, ]), delete=Mock(), ) helper = APIHelper(cache_manager=manager) helper.set_rate_limit(0) helper.clear_cache(pattern='espn') manager.delete.assert_called_once_with('espn_nfl_x') def test_pattern_manager_without_list_cache_files_is_noop(self): helper = APIHelper(cache_manager=object()) helper.set_rate_limit(0) helper.clear_cache(pattern='espn') # must not raise # --------------------------------------------------------------------------- # No cache manager # --------------------------------------------------------------------------- class TestNoCacheManager: def test_all_cache_operations_safe_without_manager(self): helper = APIHelper(cache_manager=None) helper.set_rate_limit(0) assert helper.get_cache('k') is None assert helper._get_from_cache('k') is None assert helper.set_cache('k', {'a': 1}) is None assert helper.clear_cache() is None assert helper.clear_cache(pattern='espn') is None