diff --git a/custom_components/rohlikcz/config_flow.py b/custom_components/rohlikcz/config_flow.py index 1384f42..65e6da2 100644 --- a/custom_components/rohlikcz/config_flow.py +++ b/custom_components/rohlikcz/config_flow.py @@ -20,8 +20,7 @@ DOMAIN, CONF_ANALYTICS, ANALYTICS_OPTIONS, DEFAULT_ANALYTICS, CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED, ) -from .errors import InvalidCredentialsError -from .rohlik_api import RohlikCZAPI +from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError _LOGGER = logging.getLogger(__name__) @@ -31,10 +30,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Returns the account title and unique user id on success. """ - api = RohlikCZAPI(data[CONF_EMAIL], data[CONF_PASSWORD]) - reply = await api.get_data() - user = reply["login"]["data"]["user"] - return {"title": user["name"], "user_id": str(user["id"])} + # A one-shot client that owns (and on close fully tears down) its session. + client = RohlikAPI(data[CONF_EMAIL], data[CONF_PASSWORD]) + try: + reply = await client.login() + user = reply["data"]["user"] + return {"title": user["name"], "user_id": str(user["id"])} + finally: + await client.close() ANALYTICS_SCHEMA = vol.Schema({ @@ -78,6 +81,8 @@ async def async_step_user( info = await validate_input(self.hass, user_input) except InvalidCredentialsError: errors["base"] = "invalid_auth" + except RohlikAPIError: + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unknown exception") errors["base"] = "unknown" @@ -139,6 +144,8 @@ async def async_step_reauth_confirm( info = await validate_input(self.hass, data) except InvalidCredentialsError: errors["base"] = "invalid_auth" + except RohlikAPIError: + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unknown exception") errors["base"] = "unknown" diff --git a/custom_components/rohlikcz/errors.py b/custom_components/rohlikcz/errors.py deleted file mode 100644 index 65b4d69..0000000 --- a/custom_components/rohlikcz/errors.py +++ /dev/null @@ -1,21 +0,0 @@ -from homeassistant.exceptions import HomeAssistantError - - -class RohlikczError(HomeAssistantError): - """ Base rohlik.cz integration error class. """ - - -class NotAuthorizedError(RohlikczError): - """ User is not authorized. """ - - -class InvalidCredentialsError(RohlikczError): - """ User provided wrong credentials. """ - - -class AddressNotSetError(RohlikczError): - """ No delivery address set in user account. """ - - -class APIRequestFailedError(RohlikczError): - """ An API request to Rohlik.cz failed (e.g. connection error). """ diff --git a/custom_components/rohlikcz/hub.py b/custom_components/rohlikcz/hub.py index 74e8f52..b08e94d 100644 --- a/custom_components/rohlikcz/hub.py +++ b/custom_components/rohlikcz/hub.py @@ -3,19 +3,21 @@ import json import logging import os +from dataclasses import asdict from datetime import datetime, timedelta -from typing import Any, Optional, Dict +from typing import Any from zoneinfo import ZoneInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError + from .const import DOMAIN -from .errors import InvalidCredentialsError, APIRequestFailedError, RohlikczError -from .rohlik_api import RohlikCZAPI #: How often the integration refreshes data from the Rohlik API. UPDATE_INTERVAL = timedelta(seconds=600) @@ -391,7 +393,12 @@ def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: ) self._username: str = username self._password: str = password - self._rohlik_api = RohlikCZAPI(self._username, self._password) + # A dedicated, HA-managed aiohttp session (own cookie jar) keeps each + # account's auth cookies isolated from other integrations and from a + # second Rohlik account. The client logs in lazily and re-authenticates + # transparently on a 401, reusing this session across calls. + self._session = async_create_clientsession(hass) + self._client = RohlikAPI(self._username, self._password, session=self._session) self._order_store: OrderStore | None = None self._last_refresh: datetime | None = None # _store_lock guards brief in-memory store mutations (contended by the @@ -462,11 +469,11 @@ def last_refresh(self) -> datetime | None: async def _async_update_data(self) -> dict: """Fetch data from the Rohlik API (called by the coordinator).""" try: - data = await self._rohlik_api.get_data() + data = await self._client.get_data() except InvalidCredentialsError as err: # Credentials are no longer valid - trigger the reauth flow. raise ConfigEntryAuthFailed(str(err)) from err - except (APIRequestFailedError, RohlikczError) as err: + except RohlikAPIError as err: raise UpdateFailed(str(err)) from err self._last_refresh = datetime.now(ZoneInfo("Europe/Prague")) @@ -512,7 +519,7 @@ async def _auto_enrich_new_orders(self, new_count: int) -> None: return # Fetch item details (network I/O, store lock released). - items_map = await self._rohlik_api.enrich_orders_with_items(recent_unenriched) + items_map = await self._fetch_order_items(recent_unenriched) # Apply item results and find products needing categories. async with self._store_lock: @@ -523,7 +530,7 @@ async def _auto_enrich_new_orders(self, new_count: int) -> None: # Fetch categories (network I/O, store lock released). cat_map = {} if uncategorized: - cat_map = await self._rohlik_api.fetch_product_categories_batch(uncategorized) + cat_map = await self._fetch_product_categories(uncategorized) # Persist all results. enriched = False @@ -547,7 +554,7 @@ async def fetch_full_order_history(self, hass=None) -> dict: """ async with self._enrich_lock: # Step 1: Fetch order list (network I/O, store lock released) - all_orders = await self._rohlik_api.fetch_all_delivered_orders() + all_orders = await self._client.orders.get_all_delivered() new_orders = 0 if self._order_store and all_orders: async with self._store_lock: @@ -615,7 +622,7 @@ async def _enrich_order_details(self, hass=None) -> dict: self._t("phase1").format(count=len(unenriched)), self._t("title_progress")) _LOGGER.info(f"Enriching {len(unenriched)} orders with item details...") - items_map = await self._rohlik_api.enrich_orders_with_items(unenriched) + items_map = await self._fetch_order_items(unenriched) async with self._store_lock: for order_id, items in items_map.items(): if self._order_store.add_items_to_order(order_id, items): @@ -642,7 +649,7 @@ async def progress_cb(done, total): self._t("title_progress")) _LOGGER.info(f"Fetching categories for {total_products} products...") - cat_map = await self._rohlik_api.fetch_product_categories_batch(uncategorized, progress_callback=progress_cb) + cat_map = await self._fetch_product_categories(uncategorized, progress_callback=progress_cb) async with self._store_lock: new_cats = self._order_store.update_product_categories(cat_map) stats["products_categorized"] = new_cats @@ -672,6 +679,65 @@ async def progress_cb(done, total): "products_categorized_this_run": stats["products_categorized"], } + async def _fetch_order_items(self, order_ids: list[str]) -> dict[str, list]: + """Fetch line items for a list of order IDs. + + Returns ``{order_id: items_list}`` where each item is normalised to the + shape the OrderStore expects. Orders are fetched one at a time with a + short delay to stay polite to the API. + """ + results: dict[str, list] = {} + total = len(order_ids) + for i, order_id in enumerate(order_ids): + try: + detail = await self._client.orders.get_detail(int(order_id)) + except (ValueError, RohlikAPIError) as err: + _LOGGER.warning("Failed to fetch items for order %s: %s", order_id, err) + detail = None + if detail and detail.get("items"): + results[order_id] = [ + { + "id": item.get("id"), + "name": item.get("name", "Unknown"), + "quantity": item.get("amount", 1), + "price": item.get("priceComposition", {}).get("total", {}).get("amount", 0), + "unit_price": item.get("priceComposition", {}).get("unit", {}).get("amount", 0), + "textual_amount": item.get("textualAmount", ""), + } + for item in detail["items"] + ] + if i and i % 50 == 0: + _LOGGER.info("Fetched items for %d/%d orders", i, total) + await asyncio.sleep(0.2) + _LOGGER.info("Item fetch complete: %d/%d orders", len(results), total) + return results + + async def _fetch_product_categories(self, product_ids: list[int], progress_callback=None) -> dict[int, list]: + """Fetch the category hierarchy for a batch of product IDs. + + Returns ``{product_id: categories_list}``. A product the API no longer + knows about (``get_categories`` returns ``None``) is recorded with a + sentinel "Discontinued" category so it isn't retried every run. + """ + results: dict[int, list] = {} + total = len(product_ids) + for i, pid in enumerate(product_ids): + try: + cats = await self._client.products.get_categories(pid) + except RohlikAPIError as err: + _LOGGER.debug("Failed to fetch categories for product %s: %s", pid, err) + cats = [] + if cats is None: + # Product discontinued (404) - mark with sentinel category. + results[pid] = [{"level": 1, "name": "Discontinued"}] + elif cats: + results[pid] = cats + if progress_callback and i and i % 50 == 0: + await progress_callback(i, total) + await asyncio.sleep(0.2) + _LOGGER.info("Category fetch complete: %d/%d products", len(results), total) + return results + async def refresh_slots(self) -> None: """Cheaply refresh only the delivery-slot data (for express-slot polling). @@ -680,52 +746,59 @@ async def refresh_slots(self) -> None: """ if not self.data: return - result = await self._rohlik_api.get_timeslots() + result = await self._client.delivery.get_next_slots() if result is not None: self.data["next_delivery_slot"] = result self.async_update_listeners() async def async_close(self) -> None: """Release resources held by the API client (called on unload).""" - await self._rohlik_api.async_close() + try: + # Logs out (best-effort) but leaves the injected session open... + await self._client.close() + finally: + # ...so always close the HA-managed session we created here, even + # if logout/close raised. + await self._session.close() # New service methods - async def add_to_cart(self, product_id: int, quantity: int) -> Dict: + async def add_to_cart(self, product_id: int, quantity: int) -> dict: """Add a product to the shopping cart.""" - product_list = [{"product_id": product_id, "quantity": quantity}] - result = await self._rohlik_api.add_to_cart(product_list) + added = await self._client.cart.add_items( + [{"product_id": product_id, "quantity": quantity}] + ) await self.async_update() - return result - - async def search_product(self, product_name: str, limit: int = 10, favourite: bool = False) -> Optional[Dict[str, Any]]: - """Search for a product by name.""" - result = await self._rohlik_api.search_product(product_name, limit, favourite) - return result + return {"added_products": added} - async def get_shopping_list(self, shopping_list_id: str) -> Dict[str, Any]: - """Get a shopping list by ID.""" - result = await self._rohlik_api.get_shopping_list(shopping_list_id) - return result + async def search_product(self, product_name: str, limit: int = 10, favourite: bool = False): + """Search for a product by name. Returns a SearchResults model (or None).""" + return await self._client.products.search(product_name, limit, favourite) - async def get_cart_content(self) -> Dict: - """ Retrieves cart content. """ - result = await self._rohlik_api.get_cart_content() - return result + async def get_shopping_list(self, shopping_list_id: str): + """Get a shopping list by ID. Returns a ShoppingList model.""" + return await self._client.account.get_shopping_list(shopping_list_id) - async def search_and_add(self, product_name: str, quantity: int, favourite: bool = False) -> Dict | None: - """ Searches for product by name and adds to cart""" + async def get_cart_content(self): + """Retrieve cart content. Returns a Cart model.""" + return await self._client.cart.get_content() - searched_product = await self.search_product(product_name, limit = 5, favourite=favourite) + async def search_and_add(self, product_name: str, quantity: int, favourite: bool = False) -> dict | None: + """Search for a product by name and add the top match to the cart.""" + results = await self.search_product(product_name, limit=5, favourite=favourite) - if searched_product: - await self.add_to_cart(searched_product["search_results"][0]["id"], quantity) - return {"success": True, "message": "", "added_to_cart": [searched_product["search_results"][0]]} + if results and results.results: + first = results.results[0] + await self.add_to_cart(first.id, quantity) + return {"success": True, "message": "", "added_to_cart": [asdict(first)]} - else: - return {"success": False, "message": f'No product matched when searching for "{product_name}"{' in favourites' if favourite else ''}.', "added_to_cart": []} + in_fav = " in favourites" if favourite else "" + return { + "success": False, + "message": f'No product matched when searching for "{product_name}"{in_fav}.', + "added_to_cart": [], + } - async def delete_from_cart(self, order_field_id: str) -> Dict: + async def delete_from_cart(self, order_field_id: str) -> None: """Delete a product from the shopping cart using orderFieldId.""" - result = await self._rohlik_api.delete_from_cart(order_field_id) - await self.async_update() # Refresh data after deletion - return result \ No newline at end of file + await self._client.cart.delete_item(order_field_id) + await self.async_update() # Refresh data after deletion \ No newline at end of file diff --git a/custom_components/rohlikcz/manifest.json b/custom_components/rohlikcz/manifest.json index c6ef9d3..13bd783 100644 --- a/custom_components/rohlikcz/manifest.json +++ b/custom_components/rohlikcz/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/dvejsada/HA-RohlikCZ", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/dvejsada/HA-RohlikCZ/issues", - "requirements": [], + "requirements": ["rohlik-api==0.2.0"], "version": "0.5.0" } diff --git a/custom_components/rohlikcz/rohlik_api.py b/custom_components/rohlikcz/rohlik_api.py deleted file mode 100644 index 4ab5897..0000000 --- a/custom_components/rohlikcz/rohlik_api.py +++ /dev/null @@ -1,706 +0,0 @@ -""" -RohlikCZ API Client - -This module provides an asynchronous API client for interacting with Rohlik.cz, a Czech online grocery delivery service. It allows logging in, retrieving account data, searching products, managing shopping carts, and accessing shopping lists. - -Networking uses aiohttp directly (no blocking calls in the event loop). Each -public operation uses its own ClientSession with an isolated cookie jar, so the -login/logout cycle of one operation never interferes with another running -concurrently (e.g. a background enrichment overlapping a regular refresh). - -Example: - from rohlik_api import RohlikCZAPI - - async def example(): - client = RohlikCZAPI('username@example.com', 'password') - data = await client.get_data() - print(data) -""" - -import asyncio -import json -import logging - -import aiohttp - -from typing import TypedDict, Dict -from .const import HTTP_TIMEOUT -from .errors import InvalidCredentialsError, RohlikczError, APIRequestFailedError - -_LOGGER = logging.getLogger(__name__) - -BASE_URL = "https://www.rohlik.cz" - -# Errors raised by aiohttp for connection/timeout problems. -_NETWORK_ERRORS = (aiohttp.ClientError, asyncio.TimeoutError) - - -def mask_data(input_dict): - """ Takes a dictionary and replaces all non-null values with "XXXXXXX". Null values (None) remain unchanged.""" - if not isinstance(input_dict, dict): - return input_dict - - result = {} - for key, value in input_dict.items(): - if value is None: - result[key] = None - elif isinstance(value, dict): - # Recursively mask nested dictionaries - result[key] = mask_data(value) - elif isinstance(value, list): - # Handle lists by masking each element if needed - result[key] = [mask_data(item) if isinstance(item, dict) - else "XXXXXXX" if item is not None else None - for item in value] - else: - result[key] = "XXXXXXX" - - return result - - -class Product(TypedDict): - - product_id: int - quantity: int - - -class RohlikCZAPI: - """ - API client for interacting with Rohlik.cz services. - - This class provides methods to authenticate with Rohlik.cz and perform - various operations such as retrieving account data, searching for products, - adding products to cart, and accessing shopping lists. - - Attributes: - endpoints (dict): Dictionary of available API endpoints - - """ - def __init__(self, username, password): - """ - Initialize the Rohlik API client. - - Args: - username (str): Email address used for Rohlik.cz login - password (str): Password for Rohlik.cz account - """ - self._user = username - self._pass = password - self._user_id = None - self._address_id = None - self.endpoints = {} - # A dedicated, reusable logged-in session for cheap slot polling. It has - # its own cookie jar (separate from the per-operation sessions), so it - # never clashes with get_data/service calls. Access is serialized. - self._slot_session: aiohttp.ClientSession | None = None - self._slot_lock = asyncio.Lock() - - def _new_session(self) -> aiohttp.ClientSession: - """Create a fresh client session with an isolated cookie jar.""" - return aiohttp.ClientSession(timeout=HTTP_TIMEOUT) - - def _timeslots_url(self) -> str | None: - """Build the preselected-timeslots URL, or None if no address is known.""" - if not self._address_id: - return None - return (f"{BASE_URL}/services/frontend-service/timeslots-api/0" - f"?userId={self._user_id}&addressId={self._address_id}&reasonableDeliveryTime=true") - - async def _ensure_slot_session(self) -> aiohttp.ClientSession: - """Return the reusable slot session, logging in if needed.""" - if self._slot_session is None or self._slot_session.closed: - session = self._new_session() - await self.login(session) - self._slot_session = session - return self._slot_session - - async def _reset_slot_session(self) -> None: - """Close the reusable slot session so the next call logs in fresh.""" - if self._slot_session is not None: - await self._slot_session.close() - self._slot_session = None - - async def get_timeslots(self) -> dict | None: - """Fetch only the preselected delivery slots, cheaply. - - Reuses a logged-in session so a poll is a single GET rather than a full - login/logout cycle. Re-authenticates on 401 and retries once on a - dropped keep-alive connection. Returns None if the account has no - delivery address (no slot URL to query). - """ - async with self._slot_lock: - try: - return await self._fetch_timeslots() - except aiohttp.ClientConnectionError: - # Stale keep-alive connection dropped by the server between - # polls - rebuild the session and try once more. - await self._reset_slot_session() - try: - return await self._fetch_timeslots() - except _NETWORK_ERRORS as err: - raise APIRequestFailedError(f"Cannot fetch timeslots: {err}") - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - raise APIRequestFailedError(f"Cannot fetch timeslots: {err}") - - async def _fetch_timeslots(self) -> dict | None: - session = await self._ensure_slot_session() - url = self._timeslots_url() - if url is None: - return None - async with session.get(url) as response: - if response.status != 401: - response.raise_for_status() - return await response.json(content_type=None) - # Session expired (401): the response is released as the context exits - # above, so it's now safe to close the session, log in again and retry. - await self._reset_slot_session() - session = await self._ensure_slot_session() - url = self._timeslots_url() - if url is None: - return None - async with session.get(url) as retry: - retry.raise_for_status() - return await retry.json(content_type=None) - - async def async_close(self) -> None: - """Close the reusable slot session (call on unload).""" - await self._reset_slot_session() - - async def login(self, session: aiohttp.ClientSession): - """ - Authenticate with the Rohlik.cz service. - - Args: - session (aiohttp.ClientSession): An active session to use for authentication - - Returns: - dict: The JSON response containing authentication data and user information - - Raises: - APIRequestFailedError: If the login request fails at the network level - InvalidCredentialsError / RohlikczError: If the API rejects the login - """ - - login_data = {"email": self._user, "password": self._pass, "name": ""} - login_url = f"{BASE_URL}/services/frontend-service/login" - - try: - async with session.post(login_url, json=login_data) as response: - login_response: dict = await response.json(content_type=None) - except _NETWORK_ERRORS as err: - raise APIRequestFailedError(f"Cannot connect to website! Check your internet connection and try again: {err}") - - if login_response["status"] != 200: - # The Rohlik API sometimes returns an empty "messages" array on - # failure, so extract the error message defensively to avoid an - # IndexError masking the real status code. - messages = login_response.get("messages") or [] - fallback_detail = f"status code {login_response['status']}, no message provided" - if messages and isinstance(messages[0], dict): - # Fall back when content is missing or an empty string. - error_detail = messages[0].get("content") or fallback_detail - else: - error_detail = fallback_detail - - if login_response["status"] == 401: - raise InvalidCredentialsError(error_detail) - else: - _LOGGER.error(f"Login failed. Status: {login_response['status']}, Full response: {mask_data(login_response)}") - raise RohlikczError(f"Unknown error occurred during login: {error_detail}") - - if not self._user_id: - self._user_id = login_response.get("data", {}).get("user", {}).get("id", None) - - if not self._address_id: - try: - self._address_id = login_response.get("data", {}).get("address", {}).get("id", None) - except AttributeError: - _LOGGER.error(f"Address cannot be retrieved from login data. No delivery time sensors will be added. Login response: {mask_data(login_response)}") - - return login_response - - async def logout(self, session: aiohttp.ClientSession) -> None: - """ - Log out from the Rohlik.cz service. - :param session: - :return: - """ - logout_url = f"{BASE_URL}/services/frontend-service/logout" - - try: - async with session.post(logout_url) as response: - logout_response: dict = await response.json(content_type=None) - except _NETWORK_ERRORS as err: - raise APIRequestFailedError(f"Cannot connect to website! Check your internet connection and try again: {err}") - - if logout_response["status"] != 200: - raise RohlikczError(f"Unknown error occurred during logout: {logout_response}") - - async def get_data(self): - """ - Retrieve all account data from Rohlik.cz in a single operation. - - Returns: - dict: A dictionary containing all data from various Rohlik endpoints, - including login information, delivery details, cart contents, - """ - session = self._new_session() - result: dict = {} - self.endpoints = { - "delivery": "/services/frontend-service/first-delivery?reasonableDeliveryTime=true", - "next_order": "/api/v3/orders/upcoming", - "announcements": "/services/frontend-service/announcements/top", - "bags": "/api/v1/reusable-bags/user-info", - "timeslot": "/services/frontend-service/v1/timeslot-reservation", - "last_order": "/api/v3/orders/delivered?offset=0&limit=1", - "premium_profile": "/services/frontend-service/premium/profile", - "next_delivery_slot": "/services/frontend-service/timeslots-api/", - "delivery_announcements": "/services/frontend-service/announcements/delivery", - "delivered_orders": "/api/v3/orders/delivered?offset=0&limit=50" - } - - try: - # Login first; if this fails the error propagates (logout in the - # finally is best-effort and never masks it). - result["login"] = await self.login(session) - - # Step 2: Get data from all other endpoints - for endpoint, path in self.endpoints.items(): - - if endpoint == "next_delivery_slot": - # Built (and DRYed) via the shared helper; None without an address. - url = self._timeslots_url() - if url is None: - result[endpoint] = None - continue - else: - url = f"{BASE_URL}{path}" - - try: - async with session.get(url) as response: - response.raise_for_status() - result[endpoint] = await response.json(content_type=None) - except Exception as err: - _LOGGER.error(f"Error fetching {endpoint}: {err}") - result[endpoint] = None - - try: - result["cart"] = await self.get_cart_content(logged_in=True, session=session) - except Exception as err: - _LOGGER.error(f"Error fetching cart: {err}") - result["cart"] = None - - return result - - finally: - # Step 3: Log out (best-effort) and always close the session. - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def get_delivered_orders_page(self, session: aiohttp.ClientSession, offset: int = 0, limit: int = 50) -> list: - """Fetch a page of delivered orders using an existing authenticated session.""" - url = f"{BASE_URL}/api/v3/orders/delivered?offset={offset}&limit={limit}" - try: - async with session.get(url) as response: - response.raise_for_status() - return await response.json(content_type=None) - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Error fetching delivered orders page (offset={offset}): {err}") - return [] - - async def get_order_detail(self, session: aiohttp.ClientSession, order_id: str) -> dict | None: - """Fetch detailed order info including items for a single order.""" - url = f"{BASE_URL}/api/v3/orders/{order_id}" - try: - async with session.get(url) as response: - response.raise_for_status() - return await response.json(content_type=None) - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Error fetching order detail for {order_id}: {err}") - return None - - async def get_product_categories(self, session: aiohttp.ClientSession, product_id: int) -> list | None: - """Fetch category hierarchy for a product. Returns None for 404 (discontinued).""" - url = f"{BASE_URL}/api/v1/products/{product_id}/categories" - try: - async with session.get(url) as response: - if response.status == 404: - return None # Product no longer exists - response.raise_for_status() - data = await response.json(content_type=None) - return data.get("categories", []) - except _NETWORK_ERRORS as err: - _LOGGER.debug(f"Could not fetch categories for product {product_id}: {err}") - return [] - - async def get_product_detail(self, session: aiohttp.ClientSession, product_id: int) -> dict | None: - """Fetch product detail including brand.""" - url = f"{BASE_URL}/api/v1/products/{product_id}" - try: - async with session.get(url) as response: - response.raise_for_status() - return await response.json(content_type=None) - except _NETWORK_ERRORS as err: - _LOGGER.debug(f"Could not fetch product detail for {product_id}: {err}") - return None - - async def enrich_orders_with_items(self, order_ids: list[str]) -> dict[str, list]: - """Fetch item details for a list of order IDs. Returns {order_id: items_list}.""" - session = self._new_session() - results = {} - try: - await self.login(session) - except Exception as err: - _LOGGER.error(f"Login failed for order enrichment: {err}") - await session.close() - return results - - try: - for i, order_id in enumerate(order_ids): - try: - detail = await self.get_order_detail(session, order_id) - if detail and detail.get("items"): - items = [] - for item in detail["items"]: - items.append({ - "id": item.get("id"), - "name": item.get("name", "Unknown"), - "quantity": item.get("amount", 1), - "price": item.get("priceComposition", {}).get("total", {}).get("amount", 0), - "unit_price": item.get("priceComposition", {}).get("unit", {}).get("amount", 0), - "textual_amount": item.get("textualAmount", ""), - }) - results[order_id] = items - except Exception as err: - _LOGGER.warning(f"Failed to fetch items for order {order_id}: {err}") - if i % 50 == 0 and i > 0: - _LOGGER.info(f"Fetched items for {i}/{len(order_ids)} orders") - await asyncio.sleep(0.2) - _LOGGER.info(f"Item fetch complete: {len(results)}/{len(order_ids)} orders") - return results - except Exception as err: - _LOGGER.error(f"Error during order item fetch: {err}") - return results - finally: - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def fetch_product_categories_batch(self, product_ids: list[int], progress_callback=None) -> dict[int, list]: - """Fetch categories for a batch of product IDs. Returns {product_id: categories_list}.""" - session = self._new_session() - results = {} - try: - await self.login(session) - except Exception as err: - _LOGGER.error(f"Login failed for category fetch: {err}") - await session.close() - return results - - try: - for i, pid in enumerate(product_ids): - try: - cats = await self.get_product_categories(session, pid) - if cats is None: - # Product discontinued (404) — mark with sentinel category - results[pid] = [{"level": 1, "name": "Discontinued"}] - elif cats: - results[pid] = cats - except Exception as err: - _LOGGER.debug(f"Failed to fetch categories for product {pid}: {err}") - if progress_callback and i % 50 == 0: - await progress_callback(i, len(product_ids)) - await asyncio.sleep(0.2) - _LOGGER.info(f"Category fetch complete: {len(results)}/{len(product_ids)} products") - return results - except Exception as err: - _LOGGER.error(f"Error during category fetch: {err}") - return results - finally: - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def fetch_all_delivered_orders(self) -> list: - """Fetch ALL delivered orders by paginating through the API. Returns list of all orders.""" - session = self._new_session() - all_orders = [] - offset = 0 - limit = 50 - - try: - await self.login(session) - - while True: - page = await self.get_delivered_orders_page(session, offset, limit) - if not page: - break - all_orders.extend(page) - _LOGGER.info(f"Fetched {len(all_orders)} orders so far (offset={offset})") - if len(page) < limit: - break - offset += limit - # Rate limit: 200ms between pages - await asyncio.sleep(0.2) - - return all_orders - - finally: - # Per-page network errors are handled in get_delivered_orders_page - # (which returns [] and ends pagination), so no network error can - # reach here; a login failure propagates to the caller. - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def add_to_cart(self, product_list: list[dict]) -> dict: - """ - Add multiple products to the shopping cart. - - Args: - product_list (list[dict]): A list of objects containing product_id and quantity for each product to be added to the cart - Returns: - list: A list of product IDs that were successfully added to the cart - """ - - session = self._new_session() - try: - # A login network failure surfaces as APIRequestFailedError; per-product - # failures below are caught individually. - await self.login(session) - - search_url = "/services/frontend-service/v2/cart" - added_products = [] - - for product in product_list: - search_payload = { - "actionId": None, - "productId": int(product["product_id"]), - "quantity": int(product["quantity"]), - "recipeId": None, - "source": "true:Shopping Lists" - } - try: - async with session.post(f"{BASE_URL}{search_url}", json=search_payload) as response: - response.raise_for_status() - added_products.append(product["product_id"]) - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Error adding {product['product_id']} due to {err}") - - return {"added_products": added_products} - - finally: - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def search_product(self, product_name: str, limit: int = 10, favourite: bool = False): - """ - Search for products by name and return the first matching product. - - Args: - product_name (str): The name or search term for the product - limit (int): Number of products returned - favourite (bool): Whether only favourite items shall be returned - - Returns: - dict: The first matching product's details, or None if no products found - """ - - session = self._new_session() - try: - await self.login(session) - - # Set request data - search_url = "/services/frontend-service/search-metadata" - # aiohttp query params must be flat strings, so complex values are - # JSON-encoded / stringified. - search_payload = { - "search": product_name, - "offset": "0", - "limit": str(limit + 5), - "companyId": "1", - "filterData": json.dumps({"filters": []}), - "canCorrect": "true", - } - - # Perform API request - async with session.get(f"{BASE_URL}{search_url}", params=search_payload) as response: - response.raise_for_status() - search_data: dict = await response.json(content_type=None) - - found_products: list = search_data["data"]["productList"] - - # Remove sponsored content - found_products = [p for p in found_products if - not any(badge.get("slug") == "promoted" for badge in p.get("badge", []))] - - # Keep only favourites if requested - if favourite: - found_products = [p for p in found_products if p.get("favourite", False)] - - # Keep only results up to the specified limit - if len(found_products) > limit: - found_products = found_products[:limit] - - if len(found_products) > 0: - search_results = {"search_results": []} - for i in range(len(found_products)): - search_results["search_results"].append({ - "id": found_products[i]["productId"], - "name": found_products[i]["productName"], - "price": f"{found_products[i]['price']['full']} {found_products[i]['price']['currency']}", - "brand": found_products[i]["brand"], - "amount": found_products[i]["textualAmount"] - }) - return search_results - else: - return None - - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Request failed: {err}") - return None - finally: - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def get_shopping_list(self, shopping_list_id=None) -> dict: - """ - Retrieve a shopping list by its ID. - - :param: - shopping_list_id (str, optional): The ID of the shopping list to retrieve. Must be provided. - - :return: - dict: The shopping list details - """ - - if not shopping_list_id: - raise ValueError("Missing argument - shopping list id") - - session = self._new_session() - try: - shopping_list_url = f"/api/v1/shopping-lists/id/{shopping_list_id}" - - await self.login(session) - async with session.get(f"{BASE_URL}{shopping_list_url}") as response: - response.raise_for_status() - search_data = await response.json(content_type=None) - return {"name": search_data["name"], "products_in_list": search_data["products"]} - - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Request failed: {err}") - raise ValueError("Request failed") - finally: - try: - await self.logout(session) - except Exception: - pass - await session.close() - - async def get_cart_content(self, logged_in: bool = False, session: aiohttp.ClientSession = None) -> Dict: - """ - Fetches the current cart contents - - :return: Dictionary with cart content - """ - - cart_url = "/services/frontend-service/v2/cart" - own_session = not logged_in - - if own_session: - session = self._new_session() - try: - # Login inside the try so the session is still closed if it fails. - if own_session: - await self.login(session) - async with session.get(f"{BASE_URL}{cart_url}") as response: - response.raise_for_status() - cart_content = await response.json(content_type=None) - - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Request failed: {err}") - raise ValueError("Request failed") - finally: - if own_session: - try: - await self.logout(session) - except Exception: - pass - await session.close() - - data = cart_content.get("data", {}) - - # Extract the main cart information - cart_info = { - "total_price": data.get("totalPrice", 0), - "total_items": len(data.get("items", {})), - "can_make_order": data.get("submitConditionPassed", False), - "products": [] - } - - # Process each product item - for product_id, product_data in data.get("items", {}).items(): - - product_info = { - "id": product_id, - "cart_item_id": product_data.get("orderFieldId", ""), - "name": product_data.get("productName", ""), - "quantity": product_data.get("quantity", 0), - "price": product_data.get("price", 0), - "category_name": product_data.get("primaryCategoryName", ""), - "brand": product_data.get("brand", "") - } - - cart_info["products"].append(product_info) - - return cart_info - - async def delete_from_cart(self, order_field_id: str) -> dict: - """ - Delete an item from the shopping cart using orderFieldId. - - Args: - order_field_id (str): The orderFieldId of the item to delete - - Returns: - dict: Response from the deletion operation - """ - session = self._new_session() - - try: - await self.login(session) - - delete_url = f"/services/frontend-service/v2/cart?orderFieldId={order_field_id}" - - async with session.delete(f"{BASE_URL}{delete_url}") as response: - response.raise_for_status() - try: - return await response.json(content_type=None) - except (aiohttp.ClientError, ValueError): - # Handle case where response might not be JSON - return {"success": True, "status_code": response.status} - - except _NETWORK_ERRORS as err: - _LOGGER.error(f"Error deleting item with orderFieldId {order_field_id}: {err}") - raise APIRequestFailedError(f"Failed to delete item from cart: {err}") - finally: - try: - await self.logout(session) - except Exception: - pass - await session.close() diff --git a/custom_components/rohlikcz/sensor.py b/custom_components/rohlikcz/sensor.py index c19195a..0789e50 100644 --- a/custom_components/rohlikcz/sensor.py +++ b/custom_components/rohlikcz/sensor.py @@ -827,16 +827,17 @@ class CartPriceSensor(BaseEntity, SensorEntity): @property def native_value(self) -> float: """Returns total cart price.""" - return (self._rohlik_account.data.get('cart') or {}).get('total_price', 0.0) + cart = self._rohlik_account.data.get('cart') + return cart.total_price if cart else 0.0 @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Returns cart details.""" - cart_data = self._rohlik_account.data.get('cart') or {} - if cart_data: + cart = self._rohlik_account.data.get('cart') + if cart: return { - "Total items": cart_data.get('total_items', 0), - "Can Order": cart_data.get('can_make_order', False) + "Total items": cart.total_items, + "Can Order": cart.can_make_order, } return None diff --git a/custom_components/rohlikcz/services.py b/custom_components/rohlikcz/services.py index fced6a4..a6f322d 100644 --- a/custom_components/rohlikcz/services.py +++ b/custom_components/rohlikcz/services.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import List, Dict, Any +from dataclasses import asdict +from typing import Any import logging import voluptuous as vol @@ -29,7 +30,7 @@ def _get_account(hass: HomeAssistant, config_entry_id: str): def register_services(hass: HomeAssistant) -> None: """Register services for the Rohlik integration.""" - async def async_add_to_cart_service(call: ServiceCall) -> List[int]: + async def async_add_to_cart_service(call: ServiceCall) -> dict[str, Any]: """Add product to cart service.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] product_id = call.data[ATTR_PRODUCT_ID] @@ -44,7 +45,7 @@ async def async_add_to_cart_service(call: ServiceCall) -> List[int]: _LOGGER.error(f"Failed to add product to cart: {err}") raise HomeAssistantError(f"Failed to add product to cart: {err}") - async def async_search_product_service(call: ServiceCall) -> Dict[str, Any]: + async def async_search_product_service(call: ServiceCall) -> dict[str, Any]: """Search for a product and return results.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] product_name = call.data[ATTR_PRODUCT_NAME] @@ -61,12 +62,14 @@ async def async_search_product_service(call: ServiceCall) -> Dict[str, Any]: kwargs[ATTR_FAVOURITE_ONLY] = favourite result = await account.search_product(product_name, **kwargs) - return result or {} + if not result: + return {} + return {"search_results": [asdict(item) for item in result.results]} except Exception as err: _LOGGER.error(f"Failed to search for product: {err}") raise HomeAssistantError(f"Failed to search for product: {err}") - async def async_search_and_add_product_service(call: ServiceCall) -> Dict[str, Any]: + async def async_search_and_add_product_service(call: ServiceCall) -> dict[str, Any]: """Search for a product and return results.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] product_name = call.data[ATTR_PRODUCT_NAME] @@ -88,7 +91,7 @@ async def async_search_and_add_product_service(call: ServiceCall) -> Dict[str, A raise HomeAssistantError(f"Failed to search for product: {err}") - async def async_get_shopping_list_service(call: ServiceCall) -> Dict[str, Any]: + async def async_get_shopping_list_service(call: ServiceCall) -> dict[str, Any]: """Get shopping list by ID.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] shopping_list_id = call.data[ATTR_SHOPPING_LIST_ID] @@ -96,19 +99,19 @@ async def async_get_shopping_list_service(call: ServiceCall) -> Dict[str, Any]: account = _get_account(hass, config_entry_id) try: result = await account.get_shopping_list(shopping_list_id) - return result + return asdict(result) except Exception as err: _LOGGER.error(f"Failed to get shopping list: {err}") raise HomeAssistantError(f"Failed to get shopping list: {err}") - async def async_get_cart_service(call: ServiceCall) -> Dict[str, Any]: + async def async_get_cart_service(call: ServiceCall) -> dict[str, Any]: """Get shopping cart content.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] account = _get_account(hass, config_entry_id) try: result = await account.get_cart_content() - return result + return asdict(result) except Exception as err: _LOGGER.error(f"Failed to get cart content: {err}") raise HomeAssistantError(f"Failed to get cart content: {err}") diff --git a/custom_components/rohlikcz/todo.py b/custom_components/rohlikcz/todo.py index 132f9b2..a256dd3 100644 --- a/custom_components/rohlikcz/todo.py +++ b/custom_components/rohlikcz/todo.py @@ -55,25 +55,25 @@ def __init__( @property def todo_items(self) -> list[TodoItem] | None: """Handle updated data from the hub.""" - cart_content = self._rohlik_hub.data["cart"] + cart = self._rohlik_hub.data["cart"] - if not cart_content: + if not cart: return None items = [] - for product in cart_content.get("products", []): + for product in cart.products: # Format the summary to include relevant information - summary = f"{product['name']} ({product['quantity']}) - {product['price']} Kč" + summary = f"{product.name} ({product.quantity}) - {product.price} Kč" # Use cart_item_id as the unique identifier for cart items items.append( TodoItem( summary=summary, - uid=str(product['cart_item_id']), + uid=str(product.cart_item_id), status=TodoItemStatus.NEEDS_ACTION, - description=f"Category: {product.get('category_name', '')}\n" - f"Brand: {product.get('brand', '')}\n" - f"Product ID: {product['id']}" + description=f"Category: {product.category_name}\n" + f"Brand: {product.brand}\n" + f"Product ID: {product.id}" ) ) diff --git a/hacs.json b/hacs.json index 4c70fa2..0c6568e 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Rohlík.cz Custom Integration", "filename": "rohlikcz.zip", "hide_default_branch": true, - "homeassistant": "2024.12.0", + "homeassistant": "2025.2.0", "render_readme": true, "zip_release": true } \ No newline at end of file diff --git a/readme.md b/readme.md index d86017f..f02e957 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) [![GitHub release](https://img.shields.io/github/v/release/dvejsada/HA-RohlikCZ)](https://github.com/dvejsada/HA-RohlikCZ/releases) -[![HA Version](https://img.shields.io/badge/Home%20Assistant-%3E%3D2024.12-blue)](https://www.home-assistant.io/) +[![HA Version](https://img.shields.io/badge/Home%20Assistant-%3E%3D2025.2-blue)](https://www.home-assistant.io/) Bring your **[Rohlík.cz](https://www.rohlik.cz)** grocery deliveries into Home Assistant! Track deliveries, monitor your cart, automate shopping, and never miss a delivery window — all from your smart home dashboard. diff --git a/requirements_test.txt b/requirements_test.txt index c37a52a..f790e71 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,2 +1,3 @@ pytest-homeassistant-custom-component aioresponses +rohlik-api==0.2.0 diff --git a/tests/fixtures_data.py b/tests/fixtures_data.py index 506fe01..03d7aa4 100644 --- a/tests/fixtures_data.py +++ b/tests/fixtures_data.py @@ -3,10 +3,16 @@ import copy +from rohlik_api import Cart + def sample_api_data() -> dict: - """A representative payload as returned by RohlikCZAPI.get_data().""" - return copy.deepcopy( + """A representative payload as returned by RohlikAPI.get_data(). + + Mirrors the real client: every endpoint is a raw JSON dict/list except + ``cart``, which is a typed :class:`~rohlik_api.Cart` model. + """ + data = copy.deepcopy( { "login": { "status": 200, @@ -25,7 +31,7 @@ def sample_api_data() -> dict: "delivery": {"data": {}}, "next_order": [], "announcements": {"data": {"announcements": []}}, - "bags": {"data": {"reusableBagsCount": 0}}, + "bags": {"current": 0, "max": 0}, "timeslot": None, "last_order": [ { @@ -45,11 +51,7 @@ def sample_api_data() -> dict: "priceComposition": {"total": {"amount": 750.0}}, } ], - "cart": { - "total_price": 0, - "total_items": 0, - "can_make_order": False, - "products": [], - }, } ) + data["cart"] = Cart(total_price=0, total_items=0, can_make_order=False, products=[]) + return data diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 5532a94..3580435 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -9,8 +9,9 @@ from homeassistant.data_entry_flow import FlowResultType from pytest_homeassistant_custom_component.common import MockConfigEntry +from rohlik_api import InvalidCredentialsError, RohlikAPIError + from custom_components.rohlikcz.const import DOMAIN -from custom_components.rohlikcz.errors import InvalidCredentialsError VALID = {"title": "Test User", "user_id": "123456"} USER_INPUT = {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "secret"} @@ -58,6 +59,22 @@ async def test_user_flow_invalid_auth(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "invalid_auth"} +async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: + """A network/API error surfaces a cannot_connect error on the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + with patch( + "custom_components.rohlikcz.config_flow.validate_input", + side_effect=RohlikAPIError("rohlik down"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + async def test_user_flow_unknown_error(hass: HomeAssistant) -> None: """Unexpected errors surface as 'unknown'.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 7ec8899..cc0fde6 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -25,7 +25,7 @@ async def test_diagnostics_redacts_personal_data(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "custom_components.rohlikcz.rohlik_api.RohlikCZAPI.get_data", + "custom_components.rohlikcz.hub.RohlikAPI.get_data", new=AsyncMock(return_value=sample_api_data()), ): assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/test_init.py b/tests/test_init.py index 81fb5ec..c0a0a7c 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -13,8 +13,9 @@ from homeassistant.util import dt as dt_util from pytest_homeassistant_custom_component.common import MockConfigEntry +from rohlik_api import APIRequestFailedError, InvalidCredentialsError + from custom_components.rohlikcz.const import CONF_ANALYTICS, DOMAIN -from custom_components.rohlikcz.errors import APIRequestFailedError, InvalidCredentialsError from custom_components.rohlikcz.hub import OrderStore, RohlikAccount from fixtures_data import sample_api_data @@ -30,7 +31,7 @@ def _entry() -> MockConfigEntry: def _patch_get_data(side_effect=None, return_value=None): return patch( - "custom_components.rohlikcz.rohlik_api.RohlikCZAPI.get_data", + "custom_components.rohlikcz.hub.RohlikAPI.get_data", new=AsyncMock(side_effect=side_effect, return_value=return_value), ) @@ -196,7 +197,7 @@ async def test_refresh_slots_updates_express_sensor(hass: HomeAssistant) -> None account = entry.runtime_data fresh_slots = {"data": {"expressSlot": {"timeSlotCapacityDTO": {"totalFreeCapacityPercent": 80}}}} - account._rohlik_api.get_timeslots = AsyncMock(return_value=fresh_slots) + account._client.delivery.get_next_slots = AsyncMock(return_value=fresh_slots) await account.refresh_slots() await hass.async_block_till_done() @@ -272,12 +273,12 @@ async def test_spending_breakdown_sensors_registered(hass: HomeAssistant, hass_s entry.add_to_hass(hass) no_network = { - "fetch_all_delivered_orders": AsyncMock(return_value=[]), - "enrich_orders_with_items": AsyncMock(return_value={}), - "fetch_product_categories_batch": AsyncMock(return_value={}), + "fetch_full_order_history": AsyncMock(return_value={}), + "_fetch_order_items": AsyncMock(return_value={}), + "_fetch_product_categories": AsyncMock(return_value={}), } with _patch_get_data(return_value=sample_api_data()), patch.multiple( - "custom_components.rohlikcz.rohlik_api.RohlikCZAPI", **no_network + "custom_components.rohlikcz.hub.RohlikAccount", **no_network ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -361,7 +362,7 @@ async def test_auto_enrich_applies_items_and_categories(hass: HomeAssistant, has ] ) account._order_store = store - account._rohlik_api.enrich_orders_with_items = AsyncMock( + account._fetch_order_items = AsyncMock( return_value={ "9001": [ { @@ -375,7 +376,7 @@ async def test_auto_enrich_applies_items_and_categories(hass: HomeAssistant, has ] } ) - account._rohlik_api.fetch_product_categories_batch = AsyncMock( + account._fetch_product_categories = AsyncMock( return_value={111: [{"level": 1, "name": "Dairy"}]} ) @@ -412,7 +413,7 @@ async def test_enrich_order_details_applies_results(hass: HomeAssistant, hass_st ] ) account._order_store = store - account._rohlik_api.enrich_orders_with_items = AsyncMock( + account._fetch_order_items = AsyncMock( return_value={ "9002": [ { @@ -426,7 +427,7 @@ async def test_enrich_order_details_applies_results(hass: HomeAssistant, hass_st ] } ) - account._rohlik_api.fetch_product_categories_batch = AsyncMock( + account._fetch_product_categories = AsyncMock( return_value={222: [{"level": 1, "name": "Bakery"}]} ) @@ -446,17 +447,17 @@ async def test_coordinator_refresh_updates_data(hass: HomeAssistant) -> None: first = sample_api_data() second = sample_api_data() - second["cart"]["total_items"] = 3 + second["cart"].total_items = 3 mocked = AsyncMock(side_effect=[first, second]) with patch( - "custom_components.rohlikcz.rohlik_api.RohlikCZAPI.get_data", new=mocked + "custom_components.rohlikcz.hub.RohlikAPI.get_data", new=mocked ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() account = entry.runtime_data - assert account.data["cart"]["total_items"] == 0 + assert account.data["cart"].total_items == 0 await account.async_update() await hass.async_block_till_done() - assert account.data["cart"]["total_items"] == 3 + assert account.data["cart"].total_items == 3 diff --git a/tests/test_rohlik_api.py b/tests/test_rohlik_api.py deleted file mode 100644 index 0c9658f..0000000 --- a/tests/test_rohlik_api.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Tests for the aiohttp-based RohlikCZAPI client (HTTP layer mocked).""" -from __future__ import annotations - -import re - -import aiohttp -import pytest -from aioresponses import aioresponses - -from custom_components.rohlikcz.errors import ( - APIRequestFailedError, - InvalidCredentialsError, - RohlikczError, -) -from custom_components.rohlikcz.rohlik_api import BASE_URL, RohlikCZAPI - -LOGIN_URL = f"{BASE_URL}/services/frontend-service/login" -LOGOUT_URL = f"{BASE_URL}/services/frontend-service/logout" - -LOGIN_OK = {"status": 200, "data": {"user": {"id": 123, "name": "Test User"}}} -LOGOUT_OK = {"status": 200} - - -def _api() -> RohlikCZAPI: - return RohlikCZAPI("user@example.com", "secret") - - -async def test_login_success_sets_user_id() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - api = _api() - session = api._new_session() - try: - reply = await api.login(session) - finally: - await session.close() - assert reply["data"]["user"]["id"] == 123 - assert api._user_id == 123 - assert api._address_id is None - - -async def test_login_invalid_credentials() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, payload={"status": 401, "messages": [{"content": "Bad creds"}]}) - api = _api() - session = api._new_session() - try: - with pytest.raises(InvalidCredentialsError, match="Bad creds"): - await api.login(session) - finally: - await session.close() - - -async def test_login_other_error_raises_rohlikcz() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, payload={"status": 500, "messages": []}) - api = _api() - session = api._new_session() - try: - with pytest.raises(RohlikczError, match="status code 500"): - await api.login(session) - finally: - await session.close() - - -async def test_login_network_error() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, exception=aiohttp.ClientConnectionError("boom")) - api = _api() - session = api._new_session() - try: - with pytest.raises(APIRequestFailedError): - await api.login(session) - finally: - await session.close() - - -async def test_get_data_aggregates_endpoints() -> None: - cart_payload = { - "data": { - "totalPrice": 100, - "submitConditionPassed": True, - "items": { - "111": { - "orderFieldId": "f1", - "productName": "Milk", - "quantity": 2, - "price": 40, - "primaryCategoryName": "Dairy", - "brand": "BrandX", - } - }, - } - } - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - m.get(f"{BASE_URL}/services/frontend-service/first-delivery?reasonableDeliveryTime=true", payload={"data": {}}) - m.get(f"{BASE_URL}/api/v3/orders/upcoming", payload=[]) - m.get(f"{BASE_URL}/services/frontend-service/announcements/top", payload={"data": {"announcements": []}}) - m.get(f"{BASE_URL}/api/v1/reusable-bags/user-info", payload={"data": {}}) - m.get(f"{BASE_URL}/services/frontend-service/v1/timeslot-reservation", payload=None) - m.get(f"{BASE_URL}/api/v3/orders/delivered?offset=0&limit=1", payload=[]) - m.get(f"{BASE_URL}/services/frontend-service/premium/profile", payload={"data": {}}) - m.get(f"{BASE_URL}/services/frontend-service/announcements/delivery", payload={"data": {"announcements": []}}) - m.get(f"{BASE_URL}/api/v3/orders/delivered?offset=0&limit=50", payload=[]) - m.get(f"{BASE_URL}/services/frontend-service/v2/cart", payload=cart_payload) - m.post(LOGOUT_URL, payload=LOGOUT_OK) - - result = await _api().get_data() - - assert result["login"]["data"]["user"]["id"] == 123 - # No address in login -> delivery slot endpoint skipped. - assert result["next_delivery_slot"] is None - assert result["cart"]["total_items"] == 1 - assert result["cart"]["products"][0]["name"] == "Milk" - assert result["cart"]["can_make_order"] is True - - -async def test_get_data_endpoint_failure_is_isolated() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - m.get(f"{BASE_URL}/services/frontend-service/first-delivery?reasonableDeliveryTime=true", status=500) - m.get(f"{BASE_URL}/api/v3/orders/upcoming", payload=[1, 2]) - m.get(f"{BASE_URL}/services/frontend-service/announcements/top", payload={"data": {"announcements": []}}) - m.get(f"{BASE_URL}/api/v1/reusable-bags/user-info", payload={"data": {}}) - m.get(f"{BASE_URL}/services/frontend-service/v1/timeslot-reservation", payload=None) - m.get(f"{BASE_URL}/api/v3/orders/delivered?offset=0&limit=1", payload=[]) - m.get(f"{BASE_URL}/services/frontend-service/premium/profile", payload={"data": {}}) - m.get(f"{BASE_URL}/services/frontend-service/announcements/delivery", payload={"data": {"announcements": []}}) - m.get(f"{BASE_URL}/api/v3/orders/delivered?offset=0&limit=50", payload=[]) - m.get(f"{BASE_URL}/services/frontend-service/v2/cart", payload={"data": {}}) - m.post(LOGOUT_URL, payload=LOGOUT_OK) - - result = await _api().get_data() - - # The failing endpoint becomes None; the others still populate. - assert result["delivery"] is None - assert result["next_order"] == [1, 2] - - -async def test_add_to_cart_returns_added_products() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - m.post(f"{BASE_URL}/services/frontend-service/v2/cart", payload={"ok": True}) - m.post(LOGOUT_URL, payload=LOGOUT_OK) - - result = await _api().add_to_cart([{"product_id": 555, "quantity": 2}]) - - assert result == {"added_products": [555]} - - -async def test_search_product_filters_promoted() -> None: - search_payload = { - "data": { - "productList": [ - { - "productId": 1, - "productName": "Coffee", - "price": {"full": 99, "currency": "CZK"}, - "brand": "Tchibo", - "textualAmount": "250 g", - "badge": [], - }, - { - "productId": 2, - "productName": "Sponsored Coffee", - "price": {"full": 120, "currency": "CZK"}, - "brand": "Ad", - "textualAmount": "250 g", - "badge": [{"slug": "promoted"}], - }, - ] - } - } - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - # Search builds query params, so match the path regardless of them. - m.get( - re.compile(r"^https://www\.rohlik\.cz/services/frontend-service/search-metadata"), - payload=search_payload, - ) - m.post(LOGOUT_URL, payload=LOGOUT_OK) - - result = await _api().search_product("coffee", limit=10) - - ids = [r["id"] for r in result["search_results"]] - assert ids == [1] # promoted item filtered out - assert result["search_results"][0]["price"] == "99 CZK" - - -async def test_fetch_all_delivered_orders_survives_logout_failure() -> None: - """A failing logout in the finally must not lose the fetched orders.""" - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - # Single short page -> pagination stops after one request. - m.get( - f"{BASE_URL}/api/v3/orders/delivered?offset=0&limit=50", - payload=[{"id": 1}, {"id": 2}], - ) - m.post(LOGOUT_URL, exception=aiohttp.ClientConnectionError("logout down")) - - orders = await _api().fetch_all_delivered_orders() - - assert orders == [{"id": 1}, {"id": 2}] - - -async def test_get_cart_content_standalone_login_failure() -> None: - """Standalone get_cart_content surfaces a login error (and closes its session).""" - with aioresponses() as m: - m.post(LOGIN_URL, payload={"status": 401, "messages": [{"content": "Bad creds"}]}) - with pytest.raises(InvalidCredentialsError): - await _api().get_cart_content() - - -async def test_get_timeslots_reuses_session() -> None: - """get_timeslots logs in once and reuses the session across calls.""" - login_with_addr = {"status": 200, "data": {"user": {"id": 123}, "address": {"id": 777}}} - slots = {"data": {"preselectedSlots": []}} - with aioresponses() as m: - # Login registered once (no repeat): a second login would 404 here. - m.post(LOGIN_URL, payload=login_with_addr) - m.get( - re.compile(r"^https://www\.rohlik\.cz/services/frontend-service/timeslots-api/"), - payload=slots, - repeat=True, - ) - api = _api() - first = await api.get_timeslots() - second = await api.get_timeslots() # reuses session; no second login - await api.async_close() - - assert first == slots - assert second == slots - - -async def test_get_timeslots_retries_on_401() -> None: - """On a 401 response, get_timeslots re-authenticates and retries once.""" - login_with_addr = {"status": 200, "data": {"user": {"id": 123}, "address": {"id": 777}}} - slots = {"data": {"preselectedSlots": []}} - with aioresponses() as m: - m.post(LOGIN_URL, payload=login_with_addr) # initial login - m.get(re.compile(r"^https://www\.rohlik\.cz/services/frontend-service/timeslots-api/"), status=401) - m.post(LOGIN_URL, payload=login_with_addr) # re-auth after 401 - m.get( - re.compile(r"^https://www\.rohlik\.cz/services/frontend-service/timeslots-api/"), - payload=slots, - ) - api = _api() - result = await api.get_timeslots() - await api.async_close() - assert result == slots - - -async def test_get_timeslots_no_address_returns_none() -> None: - """Without a delivery address there is no slot URL, so None is returned.""" - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) # LOGIN_OK has no address - api = _api() - result = await api.get_timeslots() - await api.async_close() - assert result is None - - -async def test_delete_from_cart_returns_json() -> None: - with aioresponses() as m: - m.post(LOGIN_URL, payload=LOGIN_OK) - m.delete( - f"{BASE_URL}/services/frontend-service/v2/cart?orderFieldId=f1", - payload={"status": 200}, - ) - m.post(LOGOUT_URL, payload=LOGOUT_OK) - - result = await _api().delete_from_cart("f1") - - assert result == {"status": 200}