diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index d645b8617c2..ebb928e72d0 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -24,16 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) data = entry.data og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) - lists = [] try: await og.login() - lists = (await og.get_my_lists())["shoppingLists"] except (AsyncIOTimeoutError, ClientError) as error: raise ConfigEntryNotReady from error except InvalidLoginException: return False - coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists) + coordinator = OurGroceriesDataUpdateCoordinator(hass, og) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index 636ebcc300a..c583fb4d5b1 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -20,13 +20,11 @@ _LOGGER = logging.getLogger(__name__) class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - def __init__( - self, hass: HomeAssistant, og: OurGroceries, lists: list[dict] - ) -> None: + def __init__(self, hass: HomeAssistant, og: OurGroceries) -> None: """Initialize global OurGroceries data updater.""" self.og = og - self.lists = lists - self._ids = [sl["id"] for sl in lists] + self.lists: list[dict] = [] + self._cache: dict[str, dict] = {} interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, @@ -35,13 +33,16 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_interval=interval, ) + async def _update_list(self, list_id: str, version_id: str) -> None: + old_version = self._cache.get(list_id, {}).get("list", {}).get("versionId", "") + if old_version == version_id: + return + self._cache[list_id] = await self.og.get_list_items(list_id=list_id) + async def _async_update_data(self) -> dict[str, dict]: """Fetch data from OurGroceries.""" - return dict( - zip( - self._ids, - await asyncio.gather( - *[self.og.get_list_items(list_id=id) for id in self._ids] - ), - ) + self.lists = (await self.og.get_my_lists())["shoppingLists"] + await asyncio.gather( + *[self._update_list(sl["id"], sl["versionId"]) for sl in self.lists] ) + return self._cache diff --git a/tests/components/ourgroceries/__init__.py b/tests/components/ourgroceries/__init__.py index 67fcb439908..6f90cb7ea1b 100644 --- a/tests/components/ourgroceries/__init__.py +++ b/tests/components/ourgroceries/__init__.py @@ -1,6 +1,6 @@ """Tests for the OurGroceries integration.""" -def items_to_shopping_list(items: list) -> dict[dict[list]]: +def items_to_shopping_list(items: list, version_id: str = "1") -> dict[dict[list]]: """Convert a list of items into a shopping list.""" - return {"list": {"items": items}} + return {"list": {"versionId": version_id, "items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py index 7f113da2633..c5fdec3ecb7 100644 --- a/tests/components/ourgroceries/conftest.py +++ b/tests/components/ourgroceries/conftest.py @@ -46,7 +46,7 @@ def mock_ourgroceries(items: list[dict]) -> AsyncMock: og = AsyncMock() og.login.return_value = True og.get_my_lists.return_value = { - "shoppingLists": [{"id": "test_list", "name": "Test List"}] + "shoppingLists": [{"id": "test_list", "name": "Test List", "versionId": "1"}] } og.get_list_items.return_value = items_to_shopping_list(items) return og diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 8686c52d79b..649e86f2b05 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -17,6 +17,10 @@ from . import items_to_shopping_list from tests.common import async_fire_time_changed +def _mock_version_id(og: AsyncMock, version: int) -> None: + og.get_my_lists.return_value["shoppingLists"][0]["versionId"] = str(version) + + @pytest.mark.parametrize( ("items", "expected_state"), [ @@ -57,8 +61,10 @@ async def test_add_todo_list_item( ourgroceries.add_item_to_list = AsyncMock() # Fake API response when state is refreshed after create + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( - [{"id": "12345", "name": "Soda"}] + [{"id": "12345", "name": "Soda"}], + version_id="2", ) await hass.services.async_call( @@ -95,6 +101,7 @@ async def test_update_todo_item_status( ourgroceries.toggle_item_crossed_off = AsyncMock() # Fake API response when state is refreshed after crossing off + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}] ) @@ -118,6 +125,7 @@ async def test_update_todo_item_status( assert state.state == "0" # Fake API response when state is refreshed after reopen + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Soda"}] ) @@ -166,6 +174,7 @@ async def test_update_todo_item_summary( ourgroceries.change_item_on_list = AsyncMock() # Fake API response when state is refreshed update + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list( [{"id": "12345", "name": "Milk"}] ) @@ -204,6 +213,7 @@ async def test_remove_todo_item( ourgroceries.remove_item_from_list = AsyncMock() # Fake API response when state is refreshed after remove + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list([]) await hass.services.async_call( @@ -224,6 +234,25 @@ async def test_remove_todo_item( assert state.state == "0" +async def test_version_id_optimization( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test that list items aren't being retrieved if version id stays the same.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + + @pytest.mark.parametrize( ("exception"), [ @@ -242,6 +271,7 @@ async def test_coordinator_error( state = hass.states.get("todo.test_list") assert state.state == "0" + _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.side_effect = exception freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass)