diff --git a/custom_components/parcelapp/__init__.py b/custom_components/parcelapp/__init__.py index 1aacb89..73c22ec 100644 --- a/custom_components/parcelapp/__init__.py +++ b/custom_components/parcelapp/__init__.py @@ -34,6 +34,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ParcelConfigEntry) -> bo coordinator = ParcelUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + # Store the coordinator in hass.data hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"coordinator": coordinator} @@ -41,8 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ParcelConfigEntry) -> bo if "platforms" not in hass.data[DOMAIN][entry.entry_id]: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) hass.data[DOMAIN][entry.entry_id]["platforms"] = PLATFORMS - - entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(async_update_entry)) if not hass.data[DOMAIN].get("processed_cleanup"): await cleanup_old_device(hass) diff --git a/custom_components/parcelapp/const.py b/custom_components/parcelapp/const.py index 74aece2..80fcc79 100644 --- a/custom_components/parcelapp/const.py +++ b/custom_components/parcelapp/const.py @@ -6,6 +6,9 @@ MIN_UPDATE_INTERVAL_SECONDS = 300 MAX_UPDATE_INTERVAL_SECONDS = 1800 CARRIER_CODE_ENDPOINT = "https://api.parcel.app/external/supported_carriers.json" +STORAGE_KEY = f"{DOMAIN}_cache" +STORAGE_VERSION = 1 +DEFAULT_RETRY_AFTER_SECONDS = 300 DELIVERY_STATUS_CODES = { -1: "None", 0: "Completed delivery.", diff --git a/custom_components/parcelapp/coordinator.py b/custom_components/parcelapp/coordinator.py index b3cd57c..09efbec 100644 --- a/custom_components/parcelapp/coordinator.py +++ b/custom_components/parcelapp/coordinator.py @@ -10,9 +10,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, PARCEL_URL, UPDATE_INTERVAL_SECONDS, CARRIER_CODE_ENDPOINT +from .const import ( + DOMAIN, + PARCEL_URL, + UPDATE_INTERVAL_SECONDS, + CARRIER_CODE_ENDPOINT, + STORAGE_KEY, + STORAGE_VERSION, + DEFAULT_RETRY_AFTER_SECONDS, +) _LOGGER = logging.getLogger(__name__) type ParcelConfigEntry = ConfigEntry[ParcelUpdateCoordinator] @@ -27,21 +36,64 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._hass = hass self.session = async_get_clientsession(self._hass) self.carrier_codes = {"carrier_codes_updated": "", "carrier_codes": {}} - update_interval_seconds = entry.options.get( + self._configured_interval_seconds = entry.options.get( "update_interval", UPDATE_INTERVAL_SECONDS ) + self._store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}_{entry.entry_id}") + self._cached_data: dict[str, Any] | None = None + self._skip_next_update = False super().__init__( hass, _LOGGER, name=DOMAIN, config_entry=entry, - update_interval=timedelta(seconds=update_interval_seconds), + update_interval=timedelta(seconds=self._configured_interval_seconds), always_update=True, ) + async def _async_setup(self) -> None: + """Load cached data from disk on startup.""" + stored = await self._store.async_load() + if stored is None: + return + + self._cached_data = stored + + # Restore carrier codes from cache + cached_carrier_codes = stored.get("carrier_codes", {}) + if cached_carrier_codes: + self.carrier_codes = { + "carrier_codes_updated": stored.get("carrier_codes_updated", ""), + "carrier_codes": cached_carrier_codes, + } + + # If cache is fresh enough, skip the first API call + cached_timestamp = stored.get("utc_timestamp") + if cached_timestamp: + try: + cache_time = datetime.strptime( + cached_timestamp, "%Y-%m-%d %H:%M:%S.%f" + ) + age_seconds = (datetime.now() - cache_time).total_seconds() + if age_seconds < self._configured_interval_seconds: + self._skip_next_update = True + _LOGGER.info( + "Cached data is fresh (%.0fs old), will skip first API call", + age_seconds, + ) + except (ValueError, TypeError): + _LOGGER.debug( + "Could not parse cached timestamp, will fetch fresh data" + ) + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the API and return the top value.""" + if self._skip_next_update and self._cached_data is not None: + self._skip_next_update = False + _LOGGER.debug("Returning cached data, skipping API call") + return self._cached_data + API_URL = f"{PARCEL_URL}?filter_mode=recent" carrier_codes_updated = self.carrier_codes["carrier_codes_updated"] try: @@ -51,24 +103,50 @@ async def _async_update_data(self) -> dict[str, Any]: if updated < datetime.now() + timedelta(hours=-12): try: response = await self.session.get(CARRIER_CODE_ENDPOINT) - response.raise_for_status() - payload = await response.text() - carrier_codes_raw_json = json.loads(payload) + if response.status == 429: + _LOGGER.warning( + "Rate limited on carrier codes endpoint, keeping existing codes" + ) + carrier_codes_raw_json = None + else: + response.raise_for_status() + payload = await response.text() + carrier_codes_raw_json = json.loads(payload) except (aiohttp.ClientError, json.JSONDecodeError, TimeoutError): - carrier_codes_raw_json = {} - carrier_codes_raw_json.update(pholder="Placeholder") - carrier_codes_raw_json.update(none="None") - carrier_codes_json = { - "carrier_codes_updated": str(datetime.now()), - "carrier_codes": {}, - } - carrier_codes_json["carrier_codes"] = carrier_codes_raw_json - self.carrier_codes = carrier_codes_json - else: - carrier_codes_json = self.carrier_codes + carrier_codes_raw_json = None + if carrier_codes_raw_json is not None: + carrier_codes_raw_json.update(pholder="Placeholder") + carrier_codes_raw_json.update(none="None") + carrier_codes_json = { + "carrier_codes_updated": str(datetime.now()), + "carrier_codes": {}, + } + carrier_codes_json["carrier_codes"] = carrier_codes_raw_json + self.carrier_codes = carrier_codes_json + carrier_codes_json = self.carrier_codes try: headers = {"api-key": self.api_key, "Content-Type": "application/json"} response = await self.session.get(API_URL, headers=headers) + + if response.status == 429: + retry_after = DEFAULT_RETRY_AFTER_SECONDS + retry_header = response.headers.get("Retry-After") + if retry_header: + try: + retry_after = int(retry_header) + except ValueError: + pass + _LOGGER.warning( + "Parcel API rate limit hit (429). Backing off for %d seconds", + retry_after, + ) + if self._cached_data is not None: + self.update_interval = timedelta(seconds=retry_after) + return self._cached_data + raise UpdateFailed( + "Rate limited by Parcel API (429) and no cached data available." + ) + response.raise_for_status() payload = await response.text() payload_json = json.loads(payload) @@ -79,7 +157,18 @@ async def _async_update_data(self) -> dict[str, Any]: payload_json["utc_timestamp"] = datetime.now().strftime( "%Y-%m-%d %H:%M:%S.%f" ) + self._cached_data = payload_json + await self._store.async_save(payload_json) + self.update_interval = timedelta(seconds=self._configured_interval_seconds) + return payload_json + except UpdateFailed: + raise except Exception as err: + if self._cached_data is not None: + _LOGGER.warning( + "Error fetching data from API: %s. Returning cached data.", err + ) + return self._cached_data raise UpdateFailed(f"Error fetching data from API: {err}") from err diff --git a/custom_components/parcelapp/sensor.py b/custom_components/parcelapp/sensor.py index 942f3bc..d8c683b 100644 --- a/custom_components/parcelapp/sensor.py +++ b/custom_components/parcelapp/sensor.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -33,7 +34,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Parcel sensor platform from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + coordinator = entry.runtime_data async_add_entities( [ RecentShipment(coordinator), @@ -45,7 +46,7 @@ async def async_setup_entry( ) -class RecentShipment(CoordinatorEntity, SensorEntity): +class RecentShipment(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of a sensor that fetches the top value from an API.""" def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: @@ -58,6 +59,14 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: self._attr_icon = "mdi:package" self._attr_state = None + async def async_added_to_hass(self) -> None: + """Restore last known state when added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_state = last_state.state + if last_state.attributes: + self._hass_custom_attributes = dict(last_state.attributes) + @property def device_info(self): """Return device information.""" @@ -141,7 +150,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class ActiveShipment(CoordinatorEntity, SensorEntity): +class ActiveShipment(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of a sensor that manipulates the data from the API, presents the next parcel due, and presents multiple attributes.""" _attr_state_class = SensorStateClass.MEASUREMENT @@ -157,6 +166,17 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: self._attr_icon = "mdi:package" self._attr_state = None + async def async_added_to_hass(self) -> None: + """Restore last known state when added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + try: + self._attr_state = int(last_state.state) + except (ValueError, TypeError): + self._attr_state = last_state.state + if last_state.attributes: + self._hass_custom_attributes = dict(last_state.attributes) + @property def device_info(self): """Return device information.""" @@ -410,7 +430,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class CollectionShipment(CoordinatorEntity, SensorEntity): +class CollectionShipment(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of a sensor that reports any parcels currently ready for collection.""" # Disabled by default @@ -428,6 +448,17 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: self._attr_icon = "mdi:package-up" self._attr_state = None + async def async_added_to_hass(self) -> None: + """Restore last known state when added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + try: + self._attr_state = int(last_state.state) + except (ValueError, TypeError): + self._attr_state = last_state.state + if last_state.attributes: + self._hass_custom_attributes = dict(last_state.attributes) + @property def device_info(self): """Return device information.""" @@ -506,7 +537,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class RawShipmentData(CoordinatorEntity, SensorEntity): +class RawShipmentData(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of a sensor that fetches the raw data from the API.""" # Disabled by default @@ -522,6 +553,14 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: self._attr_icon = "mdi:package-down" self._attr_state = None + async def async_added_to_hass(self) -> None: + """Restore last known state when added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_state = last_state.state + if last_state.attributes: + self._hass_custom_attributes = dict(last_state.attributes) + @property def device_info(self): """Return device information.""" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 6908ece..f7bc410 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -44,6 +44,7 @@ async def test_parcel_update_coordinator(hass, aioclient_mock): mock_entry = AsyncMock() mock_entry.data = {"api_key": "test_api_key"} mock_entry.options = {} + mock_entry.entry_id = "test_entry_coord" mock_entry.async_on_unload = Mock() # Initialize the coordinator diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 0000000..ae7f7f1 --- /dev/null +++ b/tests/test_persistence.py @@ -0,0 +1,320 @@ +"""Tests for data persistence and rate limit handling.""" + +import json +import pytest +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from custom_components.parcelapp.coordinator import ParcelUpdateCoordinator +from custom_components.parcelapp.const import ( + UPDATE_INTERVAL_SECONDS, + DEFAULT_RETRY_AFTER_SECONDS, +) + + +def _load_fixture(name: str) -> dict: + """Load a test fixture JSON file.""" + fixtures_path = Path(__file__).parent / "fixtures" + with open(fixtures_path / name) as file: + return json.load(file) + + +def _make_mock_entry(entry_id: str = "test_entry_123") -> Mock: + """Create a mock ConfigEntry.""" + mock_entry = AsyncMock() + mock_entry.data = {"api_key": "test_api_key"} + mock_entry.options = {} + mock_entry.entry_id = entry_id + mock_entry.state = ConfigEntryState.SETUP_IN_PROGRESS + mock_entry.async_on_unload = Mock() + return mock_entry + + +def _make_cached_data(age_seconds: float = 60.0) -> dict: + """Create cached data with a timestamp of the given age.""" + fixture = _load_fixture("recent.json") + cache_time = datetime.now() - timedelta(seconds=age_seconds) + fixture["carrier_codes_updated"] = str(datetime.now()) + fixture["carrier_codes"] = {"fedex": "FedEx", "usps": "USPS", "pholder": "Placeholder", "none": "None"} + fixture["utc_timestamp"] = cache_time.strftime("%Y-%m-%d %H:%M:%S.%f") + return fixture + + +@pytest.mark.asyncio +async def test_fresh_cache_skips_api_call(hass: HomeAssistant, aioclient_mock): + """Test that fresh cached data skips the first API call.""" + cached = _make_cached_data(age_seconds=60) # 60s old, interval is 300s + mock_entry = _make_mock_entry() + + # Mock the API — if called, will return 200 but we can track it + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + # Patch the store to return cached data + with patch.object(coordinator._store, "async_load", return_value=cached): + with patch.object(coordinator._store, "async_save") as mock_save: + await coordinator.async_config_entry_first_refresh() + + # Should NOT have saved (didn't fetch from API) + mock_save.assert_not_called() + + # Data should match the cache + assert coordinator.data is not None + assert coordinator.data["deliveries"] == cached["deliveries"] + assert coordinator.last_update_success + + +@pytest.mark.asyncio +async def test_stale_cache_triggers_api_call(hass: HomeAssistant, aioclient_mock): + """Test that stale cached data triggers an API call.""" + cached = _make_cached_data(age_seconds=600) # 600s old, interval is 300s + mock_entry = _make_mock_entry() + + fresh_data = _load_fixture("recent.json") + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=fresh_data, status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=cached): + with patch.object(coordinator._store, "async_save") as mock_save: + await coordinator.async_config_entry_first_refresh() + + # Should have saved fresh data + mock_save.assert_called_once() + + assert coordinator.data is not None + assert coordinator.last_update_success + + +@pytest.mark.asyncio +async def test_no_cache_fetches_from_api(hass: HomeAssistant, aioclient_mock): + """Test that with no cache, data is fetched from API.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save") as mock_save: + await coordinator.async_config_entry_first_refresh() + + mock_save.assert_called_once() + + assert coordinator.data is not None + assert coordinator.data["deliveries"] == _load_fixture("recent.json")["deliveries"] + + +@pytest.mark.asyncio +async def test_429_with_cache_returns_stale_data(hass: HomeAssistant, aioclient_mock): + """Test that 429 with cached data returns stale data and stretches interval.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + # First call succeeds, second returns 429 + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save"): + await coordinator.async_config_entry_first_refresh() + + assert coordinator.last_update_success + original_data = coordinator.data + + # Now simulate 429 on next refresh + aioclient_mock.clear_requests() + aioclient_mock.get( + api_url, + status=429, + headers={"Retry-After": "600"}, + ) + + await coordinator.async_refresh() + + # Should still have data (stale) and update should be "successful" (returned data) + assert coordinator.last_update_success + assert coordinator.data["deliveries"] == original_data["deliveries"] + # Interval should be stretched + assert coordinator.update_interval == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_429_without_cache_fails(hass: HomeAssistant, aioclient_mock): + """Test that 429 without cached data raises UpdateFailed.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, status=429) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save"): + # First refresh should fail since no cache and 429 + with pytest.raises(Exception): + await coordinator.async_config_entry_first_refresh() + + assert not coordinator.last_update_success + + +@pytest.mark.asyncio +async def test_429_default_retry_after(hass: HomeAssistant, aioclient_mock): + """Test that missing Retry-After header uses default backoff.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + # First call succeeds + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save"): + await coordinator.async_config_entry_first_refresh() + + # Now 429 without Retry-After header + aioclient_mock.clear_requests() + aioclient_mock.get(api_url, status=429) + + await coordinator.async_refresh() + + assert coordinator.update_interval == timedelta(seconds=DEFAULT_RETRY_AFTER_SECONDS) + + +@pytest.mark.asyncio +async def test_interval_resets_after_success(hass: HomeAssistant, aioclient_mock): + """Test that the interval resets to normal after a successful fetch following a 429.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save"): + await coordinator.async_config_entry_first_refresh() + + # Simulate 429 + aioclient_mock.clear_requests() + aioclient_mock.get(api_url, status=429, headers={"Retry-After": "900"}) + await coordinator.async_refresh() + assert coordinator.update_interval == timedelta(seconds=900) + + # Now succeed again + aioclient_mock.clear_requests() + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + + with patch.object(coordinator._store, "async_save"): + await coordinator.async_refresh() + + assert coordinator.update_interval == timedelta(seconds=UPDATE_INTERVAL_SECONDS) + assert coordinator.last_update_success + + +@pytest.mark.asyncio +async def test_carrier_codes_persisted_and_restored(hass: HomeAssistant, aioclient_mock): + """Test that carrier codes are included in cache and restored on startup.""" + cached = _make_cached_data(age_seconds=60) + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=cached): + with patch.object(coordinator._store, "async_save"): + await coordinator.async_config_entry_first_refresh() + + # Carrier codes should be restored from cache + assert "fedex" in coordinator.carrier_codes["carrier_codes"] + assert "usps" in coordinator.carrier_codes["carrier_codes"] + + +@pytest.mark.asyncio +async def test_429_on_carrier_codes_does_not_crash(hass: HomeAssistant, aioclient_mock): + """Test that 429 on carrier codes endpoint doesn't cause UnboundLocalError.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + # Carrier codes returns 429 + aioclient_mock.get(carrier_url, status=429) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save"): + # This would raise UnboundLocalError before the fix + await coordinator.async_config_entry_first_refresh() + + assert coordinator.last_update_success + assert coordinator.data["deliveries"] == _load_fixture("recent.json")["deliveries"] + + +@pytest.mark.asyncio +async def test_other_error_with_cache_returns_cached(hass: HomeAssistant, aioclient_mock): + """Test that non-429 errors return cached data when available.""" + mock_entry = _make_mock_entry() + + api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent" + aioclient_mock.get(api_url, json=_load_fixture("recent.json"), status=200) + carrier_url = "https://api.parcel.app/external/supported_carriers.json" + aioclient_mock.get(carrier_url, json={"fedex": "FedEx"}, status=200) + + coordinator = ParcelUpdateCoordinator(hass, mock_entry) + coordinator.session = async_get_clientsession(hass) + + with patch.object(coordinator._store, "async_load", return_value=None): + with patch.object(coordinator._store, "async_save"): + await coordinator.async_config_entry_first_refresh() + + original_deliveries = coordinator.data["deliveries"] + + # Now simulate a server error + aioclient_mock.clear_requests() + aioclient_mock.get(api_url, status=500) + + await coordinator.async_refresh() + + # Should still have cached data + assert coordinator.last_update_success + assert coordinator.data["deliveries"] == original_deliveries