mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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>
353 lines
13 KiB
Python
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
|