mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(sports): recover from ESPN rejecting scoreboard date ranges Since 2026-09-15 ESPN's site API answers `dates=YYYYMMDD-YYYYMMDD` with 400 "Failed to get events endpoint." for every sport. Single days, months (`YYYYMM`) and season years still work. Every season and weeks-window fetch in core failed, including the background service the scoreboards submit their season schedules to. src/common/espn_dates.py re-asks a rejected range as whole-month chunks plus the leftover edge days, which tile the window exactly (a season is 8 requests, not 213). A month that comes back with exactly 500 events is truncated (college baseball's March) and is re-asked day by day. It also clamps `limit` to 500: above that ESPN truncates silently, e.g. college football returns 25 of 68 games for one Saturday at limit=1000. BackgroundDataService recovers rejected ranges on the worker thread and advertises `handles_espn_date_ranges` so plugins can tell whether to hand it a range. SportsCore, sports_shared, ESPNDataSource and APIHelper route through the helper or the clamped limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): ESPN date-range fallback and limit clamp Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sports): stop re-sending ESPN date ranges once one is rejected Live scoreboards refresh every 30 seconds, and each refresh sent the range first, got the 400, then fetched the chunks: three requests where one used to do. After a rejection, ranges now go straight to chunks for six hours, then the range is tried again so the workaround retires itself if ESPN reverts. A single-day 400 does not set the memo, and when every chunk fails the range request supplies the error without the chunks being fetched a second time. Per-fetch chunk logging drops to debug; the rejection itself stays a warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sports): clamp limit only on ESPN scoreboard submissions The background service is generic, and limit above 500 only truncates scoreboards. /teams needs limit=1000 (college football has 762 teams and limit=500 returns 500), so a teams submission must keep its limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
277 lines
9.7 KiB
Python
277 lines
9.7 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.time.side_effect = [102.0, 105.0]
|
|
monkeypatch.setattr(api_helper_module, 'time', fake_time)
|
|
|
|
helper.set_rate_limit(5)
|
|
helper._last_request_time = 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_time == 105.0
|
|
|
|
def test_no_sleep_when_interval_elapsed(self, helper, monkeypatch):
|
|
fake_time = MagicMock()
|
|
fake_time.time.side_effect = [200.0, 201.0]
|
|
monkeypatch.setattr(api_helper_module, 'time', fake_time)
|
|
|
|
helper.set_rate_limit(5)
|
|
helper._last_request_time = 100.0
|
|
helper._enforce_rate_limit()
|
|
|
|
fake_time.sleep.assert_not_called()
|
|
assert helper._last_request_time == 201.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_without_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}
|
|
# Pin the ttl-dropped contract: CacheManager.set is called with
|
|
# (key, data) only — the cache_ttl argument is discarded.
|
|
cache.set.assert_called_once_with('k', {'a': 1})
|
|
|
|
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):
|
|
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',
|
|
params={'dates': '20260807', 'limit': ESPN_MAX_LIMIT},
|
|
cache_key='espn_football_nfl_20260807',
|
|
cache_ttl=300,
|
|
)
|
|
|
|
def test_fetch_espn_scoreboard_explicit_date(self, helper):
|
|
helper.get = Mock(return_value=None)
|
|
|
|
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
|
|
|
|
kwargs = helper.get.call_args.kwargs
|
|
assert kwargs['params'] == {'dates': '20250115', 'limit': ESPN_MAX_LIMIT}
|
|
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
|
|
|
|
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_falls_back_to_clear(self):
|
|
manager = types.SimpleNamespace(clear=Mock())
|
|
helper = APIHelper(cache_manager=manager)
|
|
helper.set_rate_limit(0)
|
|
|
|
helper.clear_cache()
|
|
|
|
manager.clear.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
|