Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rohlik_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
ShoppingList,
)

__version__ = "0.1.0"
__version__ = "0.2.0"
__all__ = [
# Main client (facade)
"RohlikAPI",
Expand Down
10 changes: 10 additions & 0 deletions rohlik_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions rohlik_api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 43 additions & 1 deletion rohlik_api/services/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,49 @@ 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:
"""Get next available delivery 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
Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down
Loading