1212import aiohttp
1313import json
1414from unittest .mock import MagicMock , patch , AsyncMock
15- from fox import validate_schedule , minutes_to_schedule_time , end_minute_inclusive_to_exclusive , FoxAPI , schedules_are_equal , FOX_CACHE_KEYS , FOX_REFRESH_SETTINGS , FOX_REFRESH_REALTIME , OPTIONS_WORK_MODE
15+ from fox import validate_schedule , minutes_to_schedule_time , end_minute_inclusive_to_exclusive , FoxAPI , schedules_are_equal , FOX_CACHE_KEYS , FOX_REFRESH_SETTINGS , FOX_REFRESH_REALTIME , OPTIONS_WORK_MODE , FOX_SETTINGS_CACHE_VERSION
1616from tests .test_infra import run_async , create_aiohttp_mock_response , create_aiohttp_mock_session
1717
1818
@@ -67,6 +67,9 @@ def __init__(self):
6767 self .device_values = {}
6868 self .device_settings = {}
6969 self .device_settings_unavailable = {}
70+ # Default to "current" so existing tests aren't affected by the stale-cache-version
71+ # self-heal check; tests exercising that mechanism explicitly set an older value
72+ self .device_settings_version = FOX_SETTINGS_CACHE_VERSION
7073 self .last_unsupported = False
7174 self .device_production_month = {}
7275 self .device_production_year = {}
@@ -4392,6 +4395,94 @@ def test_run_settings_refresh_on_age(my_predbat):
43924395 return False
43934396
43944397
4398+ def test_run_settings_refresh_forced_by_stale_cache_version (my_predbat ):
4399+ """
4400+ Test run() forces a settings/scheduler refresh, regardless of cache age, when the
4401+ persisted device_settings_version predates FOX_SETTINGS_CACHE_VERSION - a one-time
4402+ self-heal so a customer isn't stuck reusing a stale-shaped cached setting (e.g. one
4403+ missing range/unit/precision from before that shape existed) for up to FOX_REFRESH_SETTINGS
4404+ after every restart. Also verifies the version is persisted afterwards so a subsequent,
4405+ still-fresh run does NOT force another refresh.
4406+ """
4407+ print (" - test_run_settings_refresh_forced_by_stale_cache_version" )
4408+
4409+ from datetime import datetime , timezone
4410+
4411+ fox = MockFoxAPIWithRunTracking ()
4412+ fox .device_list = [{"deviceSN" : "TEST123" }]
4413+ fox .device_settings_version = 0 # Simulates a cache persisted before this version existed
4414+
4415+ # Everything else, including device_settings itself, is freshly updated
4416+ now = datetime .now (timezone .utc )
4417+ for key in FOX_CACHE_KEYS :
4418+ fox .data_age [key ] = now
4419+
4420+ result = run_async (fox .run (0 , first = False ))
4421+
4422+ assert result == True
4423+ # Forced despite a fresh device_settings cache age, because the version is stale
4424+ assert "get_device_settings:TEST123" in fox .method_calls
4425+ assert "get_scheduler:TEST123" in fox .method_calls
4426+ # The version must be bumped to current so it stops forcing a refresh going forward
4427+ assert fox .device_settings_version == FOX_SETTINGS_CACHE_VERSION
4428+
4429+ # A second run, still with a fresh cache age, must NOT force another refresh now that the
4430+ # version matches - otherwise every restart would keep hammering the API forever
4431+ fox .method_calls = []
4432+ fox .data_age ["device_settings" ] = now
4433+ result2 = run_async (fox .run (0 , first = False ))
4434+ assert result2 == True
4435+ assert "get_device_settings:TEST123" not in fox .method_calls
4436+
4437+ return False
4438+
4439+
4440+ def test_run_stale_cache_version_not_cleared_without_scheduler_success (my_predbat ):
4441+ """
4442+ Regression guard: the stale cache version must only clear once get_scheduler() (via
4443+ update_settings_from_schedule()) has actually had a chance to produce the upgraded shape -
4444+ not merely because get_device_settings() succeeded. Otherwise a cycle where settings
4445+ succeed but the scheduler read fails would wrongly mark the migration "done", permanently
4446+ losing the retry and leaving the customer stuck with the stale-shaped cached value.
4447+ """
4448+ print (" - test_run_stale_cache_version_not_cleared_without_scheduler_success" )
4449+
4450+ from datetime import datetime , timezone
4451+
4452+ class MockFoxAPISchedulerFails (MockFoxAPIWithRunTracking ):
4453+ async def get_scheduler (self , deviceSN ):
4454+ self .method_calls .append (f"get_scheduler:{ deviceSN } " )
4455+ return None # Simulates a failed scheduler read this cycle
4456+
4457+ fox = MockFoxAPISchedulerFails ()
4458+ fox .device_list = [{"deviceSN" : "TEST123" }]
4459+ fox .device_settings_version = 0
4460+
4461+ now = datetime .now (timezone .utc )
4462+ for key in FOX_CACHE_KEYS :
4463+ fox .data_age [key ] = now
4464+
4465+ result = run_async (fox .run (0 , first = False ))
4466+
4467+ assert result == True
4468+ # Settings still succeeded and get attempted every cycle while the version stays stale
4469+ assert "get_device_settings:TEST123" in fox .method_calls
4470+ assert "get_scheduler:TEST123" in fox .method_calls
4471+ # Version must remain stale - the scheduler read (and any range upgrade it would apply)
4472+ # never actually succeeded
4473+ assert fox .device_settings_version == 0
4474+
4475+ # A second run, still with a fresh device_settings cache age, must keep retrying rather
4476+ # than silently giving up because the version never cleared
4477+ fox .method_calls = []
4478+ result2 = run_async (fox .run (0 , first = False ))
4479+ assert result2 == True
4480+ assert "get_device_settings:TEST123" in fox .method_calls
4481+ assert "get_scheduler:TEST123" in fox .method_calls
4482+
4483+ return False
4484+
4485+
43954486def test_run_realtime_refresh_after_cache_expires (my_predbat ):
43964487 """
43974488 Test run() leaves fresh real-time data alone but re-fetches it once the cache expires,
@@ -4458,15 +4549,17 @@ def test_run_device_list_failure_does_not_mark_cache_fresh(my_predbat):
44584549def test_run_first_refreshes_device_list_despite_fresh_cache (my_predbat ):
44594550 """
44604551 Test run() always re-fetches the device list on first start, even when the cached data
4461- is still fresh, so a new inverter or changed serial number is picked up. Device detail
4462- and all other age-gated categories are skipped while the cache is fresh.
4552+ is still fresh, so a new inverter or changed serial number is picked up. Device detail
4553+ and all other age-gated categories are skipped while the cache is fresh and the settings
4554+ cache version is current.
44634555 """
44644556 print (" - test_run_first_refreshes_device_list_despite_fresh_cache" )
44654557
44664558 from datetime import datetime , timezone
44674559
44684560 fox = MockFoxAPIWithRunTracking ()
44694561 fox .device_list = [{"deviceSN" : "TEST123" }]
4562+ fox .device_settings_version = FOX_SETTINGS_CACHE_VERSION
44704563
44714564 # Mark every cache category as freshly updated
44724565 now = datetime .now (timezone .utc )
@@ -4478,7 +4571,8 @@ def test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat):
44784571 assert result == True
44794572 # Device list must always refresh on first start regardless of cache age
44804573 assert "get_device_list" in fox .method_calls
4481- # Age-based categories with fresh data should NOT be re-fetched on first start
4574+ # Age-based categories with fresh data and a current settings cache version should NOT be
4575+ # re-fetched on first start
44824576 assert "get_device_detail:TEST123" not in fox .method_calls
44834577 assert "get_real_time_data:TEST123" not in fox .method_calls
44844578 assert "get_device_settings:TEST123" not in fox .method_calls
@@ -4536,6 +4630,7 @@ def test_run_unchanged_device_list_preserves_cache(my_predbat):
45364630
45374631 fox = MockFoxAPIWithRunTracking ()
45384632 fox .device_list = [{"deviceSN" : "TEST123" }]
4633+ fox .device_settings_version = FOX_SETTINGS_CACHE_VERSION
45394634 now = datetime .now (timezone .utc )
45404635 for key in FOX_CACHE_KEYS :
45414636 fox .data_age [key ] = now
@@ -6735,6 +6830,8 @@ def run_fox_api_tests(my_predbat):
67356830 failed |= test_run_first_call_no_devices (my_predbat )
67366831 failed |= test_run_subsequent_call (my_predbat )
67376832 failed |= test_run_settings_refresh_on_age (my_predbat )
6833+ failed |= test_run_settings_refresh_forced_by_stale_cache_version (my_predbat )
6834+ failed |= test_run_stale_cache_version_not_cleared_without_scheduler_success (my_predbat )
67386835 failed |= test_run_realtime_refresh_after_cache_expires (my_predbat )
67396836 failed |= test_run_device_list_failure_does_not_mark_cache_fresh (my_predbat )
67406837 failed |= test_run_first_refreshes_device_list_despite_fresh_cache (my_predbat )
0 commit comments