Files
LEDMatrix/test/test_api_helper.py
ChuckandClaude Opus 5.5 084697346b feat(fetch): one ESPN scoreboard cache key and a max-age response cache (fetch service stage 2) (#728)
Fetch service stage 2: one ESPN scoreboard cache key shared across the sports base classes (legacy keys still read), and a max-age response cache in the fetch service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:15:28 -04:00

353 lines
13 KiB
Python

"""
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