mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes - font_manager: a .zip font URL is served as its extracted font after a restart (the cached-file check returned the archive first); downloads use requests with a 30s timeout into a temp file + os.replace. - api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use time.monotonic(); last_request_time and the status file's ts stay wall-clock. set_on_new_cycle docstring no longer claims core uses it. - logo_helper: the placeholder uses the same scaled box as a real logo. - permission_utils: one _sudo_bash_candidates() helper (with the sudoers exact-argv rationale) shared by sudo_remove_directory, which now retries the next bash path on a sudo refusal, and install_requirements_file. - dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate INFO log and contradictory docstring example fixed. - element_style: scale default looked up through element aliases. - background_data_service: cache-hit callback runs outside the lock. - config_arrays: union-aware type check (["array","null"]); stale dotToNested() reference removed. - auto_update_setup: non-dict auto_update reads as off; temp result file unlinked when the write fails. - exceptions: constructors copy the caller's context dict. - logging_config: StructuredFormatter json.dumps(default=str). - error_aggregator: removed unused export_path/export_to_file/_auto_export. - Docstrings: validate_file_upload max_size_mb, raise_on_errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): retry the status-file rename like the other atomic writers On Windows os.replace can fail with "Access is denied" while a scanner briefly holds the target open; config_manager_atomic._replace already retries that (and re-raises at once on other platforms). The sync status writer called os.replace directly, which made test_concurrent_writers_each_use_their_own_temp_file flaky on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
12 KiB
Python
328 lines
12 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):
|
|
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_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
|