From c5887f3b3f23c8e0d040ae12f77f388d7cb10618 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 09:31:55 +0000 Subject: [PATCH] Resolve delivery address_id from address list for timeslots The login response does not always include a delivery address (data.address is null for some accounts), leaving user_id present but address_id None, so get_next_slots could not build the timeslots URL and returned None. Add delivery.get_addresses() and get_active_address_id() backed by the /delivery-address/list endpoint, and have get_next_slots fall back to the resolved address (preferring isDeliveredTo) and cache it on the auth manager. Bump version to 0.2.0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018KXnkpGcLfWU4p12TcZjfY --- rohlik_api/__init__.py | 2 +- rohlik_api/auth.py | 10 +++++ rohlik_api/endpoints.py | 1 + rohlik_api/services/delivery.py | 44 +++++++++++++++++++++- tests/test_services.py | 67 +++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index cc877c9..ad1f1cd 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -33,7 +33,7 @@ ShoppingList, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ # Main client (facade) "RohlikAPI", diff --git a/rohlik_api/auth.py b/rohlik_api/auth.py index df2a300..bc42e28 100644 --- a/rohlik_api/auth.py +++ b/rohlik_api/auth.py @@ -57,6 +57,16 @@ def address_id(self) -> int | None: """Get the current address ID.""" return self._address_id + @address_id.setter + def address_id(self, value: int | None) -> None: + """Set the address ID. + + Used to cache an address resolved from the delivery-address list when + the login response did not include one (the API does not always + provide an address on login). + """ + self._address_id = value + async def login(self) -> dict[str, Any]: """Authenticate with the Rohlik.cz service. diff --git a/rohlik_api/endpoints.py b/rohlik_api/endpoints.py index f268af6..67add9c 100644 --- a/rohlik_api/endpoints.py +++ b/rohlik_api/endpoints.py @@ -24,6 +24,7 @@ class Endpoints: DELIVERY = "/services/frontend-service/first-delivery?reasonableDeliveryTime=true" TIMESLOT_RESERVATION = "/services/frontend-service/v1/timeslot-reservation" DELIVERY_ANNOUNCEMENTS = "/services/frontend-service/announcements/delivery" + DELIVERY_ADDRESS_LIST = "/services/frontend-service/delivery-address/list" # Orders NEXT_ORDER = "/api/v3/orders/upcoming" diff --git a/rohlik_api/services/delivery.py b/rohlik_api/services/delivery.py index e11d524..e7b2bd2 100644 --- a/rohlik_api/services/delivery.py +++ b/rohlik_api/services/delivery.py @@ -30,6 +30,40 @@ async def get_timeslot_reservation(self) -> dict[str, Any] | None: """ return await self._fetch_endpoint(Endpoints.TIMESLOT_RESERVATION, "timeslot reservation") + async def get_addresses(self) -> dict[str, Any] | None: + """Get the account's saved delivery addresses. + + Returns: + dict: The ``{status, messages, data}`` envelope whose ``data`` is a + list of saved addresses, or None if the request fails. + """ + return await self._fetch_endpoint(Endpoints.DELIVERY_ADDRESS_LIST, "delivery addresses") + + async def get_active_address_id(self) -> int | None: + """Resolve the delivery address ID to use for timeslot lookups. + + The login response does not always include an address, so fall back to + the saved delivery-address list. Prefers the first address the account + is currently delivered to (``isDeliveredTo``), otherwise the first one. + + Returns: + int: The resolved address ID, or None if none could be found. + """ + response = await self.get_addresses() + addresses = response.get("data") if isinstance(response, dict) else None + if not isinstance(addresses, list) or not addresses: + return None + + def address_id_of(entry: Any) -> int | None: + return ((entry or {}).get("address") or {}).get("id") + + for entry in addresses: + if ((entry or {}).get("address") or {}).get("isDeliveredTo"): + chosen = address_id_of(entry) + if chosen is not None: + return chosen + return address_id_of(addresses[0]) + async def get_next_slots( self, user_id: int | None = None, address_id: int | None = None ) -> dict[str, Any] | None: @@ -37,7 +71,8 @@ async def get_next_slots( Args: user_id: User ID (uses auth manager's user_id if not provided) - address_id: Address ID (uses auth manager's address_id if not provided) + address_id: Address ID (uses auth manager's address_id if not provided; + falls back to the saved delivery-address list when unknown) Returns: dict: Available delivery slots or None if request fails @@ -49,6 +84,13 @@ async def get_next_slots( uid = user_id or self._auth.user_id aid = address_id or self._auth.address_id + if not aid: + # The login response did not carry an address; resolve it from the + # saved address list and cache it for subsequent calls. + aid = await self.get_active_address_id() + if aid is not None: + self._auth.address_id = aid + if not uid or not aid: _LOGGER.warning("User ID or Address ID not available for timeslots request") return None diff --git a/tests/test_services.py b/tests/test_services.py index 2ad1646..37eb6ae 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -5,6 +5,7 @@ import pytest from rohlik_api.auth import AuthManager +from rohlik_api.endpoints import Endpoints from rohlik_api.http_client import HttpClient from rohlik_api.services import ( AccountService, @@ -456,12 +457,78 @@ async def test_get_next_slots_returns_none_without_ids(self, mock_http, mock_aut """Test get_next_slots returns None when IDs are missing.""" mock_auth.user_id = None mock_auth.address_id = None + # The address list also yields nothing, so no address can be resolved. + empty = MagicMock() + empty.json.return_value = {"status": 200, "data": []} + empty.raise_for_status = MagicMock() + mock_http.get.return_value = empty service = DeliveryService(mock_http, mock_auth) result = await service.get_next_slots() assert result is None + # Real (anonymised) payload shape from /delivery-address/list. + _ADDRESS_LIST = { + "status": 200, + "messages": [], + "data": [ + {"address": {"id": 11723996, "isDeliveredTo": True}, "store": {"storeId": 8799}}, + {"address": {"id": 5436937, "isDeliveredTo": True}, "store": {"storeId": 8799}}, + ], + } + + async def test_get_addresses_returns_envelope(self, mock_http, mock_auth): + """get_addresses returns the delivery-address list envelope.""" + resp = MagicMock() + resp.json.return_value = self._ADDRESS_LIST + resp.raise_for_status = MagicMock() + mock_http.get.return_value = resp + + service = DeliveryService(mock_http, mock_auth) + result = await service.get_addresses() + + assert result["data"][0]["address"]["id"] == 11723996 + assert mock_http.get.call_args[0][0] == Endpoints.DELIVERY_ADDRESS_LIST + + async def test_get_active_address_id_prefers_delivered_to(self, mock_http, mock_auth): + """get_active_address_id returns the first isDeliveredTo address id.""" + resp = MagicMock() + resp.json.return_value = { + "data": [ + {"address": {"id": 1, "isDeliveredTo": False}}, + {"address": {"id": 22, "isDeliveredTo": True}}, + ] + } + resp.raise_for_status = MagicMock() + mock_http.get.return_value = resp + + service = DeliveryService(mock_http, mock_auth) + assert await service.get_active_address_id() == 22 + + async def test_get_next_slots_resolves_address_from_list(self, mock_http, mock_auth): + """When login lacks an address, get_next_slots resolves it from the list.""" + mock_auth.address_id = None # login did not provide one + + address_resp = MagicMock() + address_resp.json.return_value = self._ADDRESS_LIST + address_resp.raise_for_status = MagicMock() + slots_resp = MagicMock() + slots_resp.json.return_value = {"slots": ["x"]} + slots_resp.raise_for_status = MagicMock() + # First GET resolves the address list, second GET fetches the slots. + mock_http.get.side_effect = [address_resp, slots_resp] + + service = DeliveryService(mock_http, mock_auth) + result = await service.get_next_slots() + + assert result == {"slots": ["x"]} + slots_url = mock_http.get.call_args_list[1][0][0] + assert "userId=12345" in slots_url + assert "addressId=11723996" in slots_url + # the resolved id is cached back on the auth manager + assert mock_auth.address_id == 11723996 + class TestAccountService: """Tests for AccountService."""