mirror of
https://github.com/home-assistant/core.git
synced 2026-09-23 10:14:41 +01:00
Bump cookidoo-api to 0.18.4 and persist the OAuth2 tokens (#181382)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7e23c4b38e
commit
8777713f71
@@ -2,7 +2,11 @@
|
||||
|
||||
import logging
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooRequestException
|
||||
from cookidoo_api import (
|
||||
CookidooAuthException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -98,7 +102,11 @@ async def async_migrate_entry(
|
||||
try:
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
except (CookidooRequestException, CookidooAuthException) as e:
|
||||
except (
|
||||
CookidooAuthException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
) as e:
|
||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
||||
return False
|
||||
|
||||
@@ -114,7 +122,11 @@ async def async_migrate_entry(
|
||||
try:
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
except (CookidooRequestException, CookidooAuthException) as e:
|
||||
except (
|
||||
CookidooAuthException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
) as e:
|
||||
_LOGGER.error("Could not migrate config entry: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from cookidoo_api import (
|
||||
CookidooAuthException,
|
||||
CookidooException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
from cookidoo_api import CookidooAuthException, CookidooException
|
||||
from cookidoo_api.types import CookidooCalendarDayRecipe
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
@@ -82,14 +78,14 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
||||
except CookidooAuthException:
|
||||
try:
|
||||
await self.coordinator.cookidoo.login()
|
||||
except (CookidooAuthException, CookidooRequestException) as exc:
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
except CookidooException as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="calendar_fetch_failed",
|
||||
) from exc
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
except CookidooException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Config flow for Cookidoo integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from cookidoo_api import (
|
||||
CookidooAuthData,
|
||||
CookidooAuthException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
get_country_options,
|
||||
get_localization_options,
|
||||
@@ -18,7 +21,14 @@ from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||
from homeassistant.const import (
|
||||
CONF_COUNTRY,
|
||||
CONF_EMAIL,
|
||||
CONF_LANGUAGE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import (
|
||||
CountrySelector,
|
||||
CountrySelectorConfig,
|
||||
@@ -61,6 +71,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
user_input: dict[str, Any]
|
||||
user_uuid: str
|
||||
# A login whose token response carries no refresh token leaves the library
|
||||
# with nothing to hand us, and the entry is then created without tokens
|
||||
token: dict[str, Any] = {}
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any]
|
||||
@@ -119,7 +132,12 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
if self.source == SOURCE_USER:
|
||||
return self.async_create_entry(
|
||||
title="Cookidoo", data={**self.user_input, **language_input}
|
||||
title="Cookidoo",
|
||||
data={
|
||||
**self.user_input,
|
||||
**language_input,
|
||||
CONF_TOKEN: self.token,
|
||||
},
|
||||
)
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -128,6 +146,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
**reconfigure_entry.data,
|
||||
**self.user_input,
|
||||
**language_input,
|
||||
CONF_TOKEN: self.token,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -160,7 +179,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(self.user_uuid)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data_updates=user_input
|
||||
reauth_entry, data_updates={**user_input, CONF_TOKEN: self.token}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
@@ -200,6 +219,11 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
}
|
||||
|
||||
@callback
|
||||
def _save_token(self, auth_data: CookidooAuthData) -> None:
|
||||
"""Keep the tokens the library hands us during the validation requests."""
|
||||
self.token = asdict(auth_data)
|
||||
|
||||
async def validate_input(
|
||||
self,
|
||||
user_input: dict[str, Any],
|
||||
@@ -222,14 +246,21 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await get_localization_options(country=data_input[CONF_COUNTRY].lower())
|
||||
)[0].language # Pick any language to test login
|
||||
|
||||
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
|
||||
# Only this attempt's tokens may reach the entry: a login that yields
|
||||
# none leaves _save_token uncalled, and an earlier attempt may have
|
||||
# stored a pair, for another account in a reauth
|
||||
self.token = {}
|
||||
cookidoo = await cookidoo_from_config_data(
|
||||
self.hass, data_input, on_auth_data_update=self._save_token
|
||||
)
|
||||
try:
|
||||
await cookidoo.login()
|
||||
user_info = await cookidoo.get_user_info()
|
||||
self.user_uuid = user_info.id
|
||||
if language_input:
|
||||
await cookidoo.get_additional_items()
|
||||
except CookidooRequestException:
|
||||
except CookidooRequestException, CookidooParseException:
|
||||
# login() scrapes the CIAM login page, so it can also fail to parse it
|
||||
errors["base"] = "cannot_connect"
|
||||
except CookidooAuthException:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -11,6 +11,7 @@ from cookidoo_api import (
|
||||
CookidooAuthException,
|
||||
CookidooException,
|
||||
CookidooIngredientItem,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
CookidooSubscription,
|
||||
CookidooUserInfo,
|
||||
@@ -60,11 +61,20 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
)
|
||||
self.cookidoo = cookidoo
|
||||
|
||||
async def _async_login(self) -> CookidooUserInfo:
|
||||
"""Return the user info, reusing the persisted tokens while they are valid."""
|
||||
if self.cookidoo.auth_data is not None:
|
||||
try:
|
||||
return await self.cookidoo.get_user_info()
|
||||
except CookidooAuthException:
|
||||
_LOGGER.debug("Stored tokens are no longer valid, logging in again")
|
||||
await self.cookidoo.login()
|
||||
return await self.cookidoo.get_user_info()
|
||||
|
||||
@override
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
await self.cookidoo.login()
|
||||
self.user = await self.cookidoo.get_user_info()
|
||||
self.user = await self._async_login()
|
||||
except CookidooRequestException as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -78,6 +88,12 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
||||
},
|
||||
) from e
|
||||
except CookidooParseException as e:
|
||||
# login() scrapes the CIAM login page, so it can also fail to parse it
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_request_exception",
|
||||
) from e
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> CookidooData:
|
||||
@@ -99,7 +115,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
||||
},
|
||||
) from exc
|
||||
except CookidooRequestException as exc:
|
||||
except (CookidooRequestException, CookidooParseException) as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_request_exception",
|
||||
|
||||
@@ -4,13 +4,14 @@ from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CookidooConfigEntry
|
||||
|
||||
TO_REDACT = [
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
"""Helpers for cookidoo."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
|
||||
from cookidoo_api import (
|
||||
Cookidoo,
|
||||
CookidooAuthData,
|
||||
CookidooConfig,
|
||||
get_localization_options,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import (
|
||||
CONF_COUNTRY,
|
||||
CONF_EMAIL,
|
||||
CONF_LANGUAGE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .coordinator import CookidooConfigEntry
|
||||
|
||||
|
||||
async def cookidoo_from_config_data(
|
||||
hass: HomeAssistant, data: dict[str, Any]
|
||||
hass: HomeAssistant,
|
||||
data: dict[str, Any],
|
||||
on_auth_data_update: Callable[[CookidooAuthData], None] | None = None,
|
||||
) -> Cookidoo:
|
||||
"""Build cookidoo from config data."""
|
||||
localizations = await get_localization_options(
|
||||
@@ -28,6 +43,7 @@ async def cookidoo_from_config_data(
|
||||
password=data[CONF_PASSWORD],
|
||||
localization=localizations[0],
|
||||
),
|
||||
on_auth_data_update=on_auth_data_update,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,4 +51,17 @@ async def cookidoo_from_config_entry(
|
||||
hass: HomeAssistant, entry: CookidooConfigEntry
|
||||
) -> Cookidoo:
|
||||
"""Build cookidoo from config entry."""
|
||||
return await cookidoo_from_config_data(hass, dict(entry.data))
|
||||
|
||||
@callback
|
||||
def save_auth_data(auth_data: CookidooAuthData) -> None:
|
||||
"""Store the tokens, so a restart does not need a new login."""
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_TOKEN: asdict(auth_data)}
|
||||
)
|
||||
|
||||
cookidoo = await cookidoo_from_config_data(
|
||||
hass, dict(entry.data), on_auth_data_update=save_auth_data
|
||||
)
|
||||
if token := entry.data.get(CONF_TOKEN):
|
||||
cookidoo.apply_auth_data(CookidooAuthData(**token))
|
||||
return cookidoo
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["cookidoo_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["cookidoo-api==0.17.2"]
|
||||
"requirements": ["cookidoo-api==0.18.4"]
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -809,7 +809,7 @@ connect-box==0.3.1
|
||||
construct==2.10.68
|
||||
|
||||
# homeassistant.components.cookidoo
|
||||
cookidoo-api==0.17.2
|
||||
cookidoo-api==0.18.4
|
||||
|
||||
# homeassistant.components.backup
|
||||
# homeassistant.components.utility_meter
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Common fixtures for the Cookidoo tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from cookidoo_api import (
|
||||
CookidooAdditionalItem,
|
||||
CookidooAuthData,
|
||||
CookidooIngredientItem,
|
||||
CookidooSubscription,
|
||||
CookidooUserInfo,
|
||||
@@ -13,7 +16,13 @@ from cookidoo_api.types import CookidooCalendarDay, CookidooCalendarDayRecipe
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cookidoo.const import DOMAIN
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||
from homeassistant.const import (
|
||||
CONF_COUNTRY,
|
||||
CONF_EMAIL,
|
||||
CONF_LANGUAGE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
@@ -24,6 +33,17 @@ LANGUAGE = "de-CH"
|
||||
|
||||
TEST_UUID = "sub_uuid"
|
||||
|
||||
AUTH_DATA = CookidooAuthData(
|
||||
access_token="test-access-token",
|
||||
refresh_token="test-refresh-token",
|
||||
expires_at=1762000000.0,
|
||||
)
|
||||
STALE_AUTH_DATA = CookidooAuthData(
|
||||
access_token="stale-access-token",
|
||||
refresh_token="stale-refresh-token",
|
||||
expires_at=1761000000.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
@@ -34,52 +54,122 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cookidoo_client() -> Generator[AsyncMock]:
|
||||
"""Mock a Cookidoo client."""
|
||||
@pytest.fixture(name="mock_cookidoo")
|
||||
def mock_cookidoo_class() -> Generator[MagicMock]:
|
||||
"""Mock the Cookidoo class the integration instantiates."""
|
||||
with patch(
|
||||
"homeassistant.components.cookidoo.helpers.Cookidoo",
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.login.return_value = None
|
||||
client.get_ingredient_items.return_value = [
|
||||
CookidooIngredientItem(**item)
|
||||
for item in load_json_object_fixture("ingredient_items.json", DOMAIN)[
|
||||
"data"
|
||||
]
|
||||
]
|
||||
client.get_additional_items.return_value = [
|
||||
CookidooAdditionalItem(**item)
|
||||
for item in load_json_object_fixture("additional_items.json", DOMAIN)[
|
||||
"data"
|
||||
]
|
||||
]
|
||||
client.get_active_subscription.return_value = CookidooSubscription(
|
||||
**load_json_object_fixture("subscriptions.json", DOMAIN)["data"]
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notify_auth_data_update(
|
||||
mock_cookidoo: MagicMock,
|
||||
) -> Callable[[CookidooAuthData | None], None]:
|
||||
"""Emulate the library notifying its consumer of new tokens.
|
||||
|
||||
A token response without a refresh token leaves the library with nothing to
|
||||
hand over, which is what passing None stands for.
|
||||
"""
|
||||
|
||||
def _notify(auth_data: CookidooAuthData | None) -> None:
|
||||
if auth_data is None:
|
||||
return
|
||||
mock_cookidoo.return_value.auth_data = auth_data
|
||||
mock_cookidoo.call_args.kwargs["on_auth_data_update"](auth_data)
|
||||
|
||||
return _notify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_success(
|
||||
notify_auth_data_update: Callable[[CookidooAuthData | None], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Emulate a successful login: fresh tokens, handed to the consumer."""
|
||||
|
||||
def _login() -> None:
|
||||
notify_auth_data_update(AUTH_DATA)
|
||||
|
||||
return _login
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cookidoo_client(
|
||||
mock_cookidoo: MagicMock,
|
||||
login_success: Callable[[], None],
|
||||
) -> AsyncMock:
|
||||
"""Mock a Cookidoo client."""
|
||||
client = mock_cookidoo.return_value
|
||||
# No tokens until a login provides them or the consumer restores them
|
||||
client.auth_data = None
|
||||
client.login.side_effect = login_success
|
||||
client.apply_auth_data.side_effect = lambda auth_data: setattr(
|
||||
client, "auth_data", auth_data
|
||||
)
|
||||
client.get_ingredient_items.return_value = [
|
||||
CookidooIngredientItem(**item)
|
||||
for item in load_json_object_fixture("ingredient_items.json", DOMAIN)["data"]
|
||||
]
|
||||
client.get_additional_items.return_value = [
|
||||
CookidooAdditionalItem(**item)
|
||||
for item in load_json_object_fixture("additional_items.json", DOMAIN)["data"]
|
||||
]
|
||||
client.get_active_subscription.return_value = CookidooSubscription(
|
||||
**load_json_object_fixture("subscriptions.json", DOMAIN)["data"]
|
||||
)
|
||||
client.get_user_info.return_value = CookidooUserInfo(
|
||||
**load_json_object_fixture("user_info.json", DOMAIN)["data"]
|
||||
)
|
||||
client.get_recipes_in_calendar_week.return_value = [
|
||||
CookidooCalendarDay(
|
||||
id=day["id"],
|
||||
title=day["title"],
|
||||
recipes=[
|
||||
CookidooCalendarDayRecipe(
|
||||
id=recipe["id"],
|
||||
name=recipe["name"],
|
||||
total_time=recipe["total_time"],
|
||||
thumbnail=recipe["thumbnail"],
|
||||
image=recipe["image"],
|
||||
url=recipe["url"],
|
||||
)
|
||||
for recipe in day["recipes"]
|
||||
],
|
||||
)
|
||||
client.get_user_info.return_value = CookidooUserInfo(
|
||||
**load_json_object_fixture("user_info.json", DOMAIN)["data"]
|
||||
for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"]
|
||||
]
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def arrange_validation_tokens(
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
notify_auth_data_update: Callable[[CookidooAuthData | None], None],
|
||||
) -> Callable[[CookidooAuthData | None, CookidooAuthData | None], None]:
|
||||
"""Arrange the tokens the config flow validation requests hand over.
|
||||
|
||||
The login hands over the first, and the additional items fetch that follows
|
||||
it the second, which is how a request rotating the tokens mid-validation
|
||||
presents itself. Either can be None, for a response without a token.
|
||||
"""
|
||||
|
||||
def _arrange(
|
||||
login_tokens: CookidooAuthData | None,
|
||||
rotated_tokens: CookidooAuthData | None,
|
||||
) -> None:
|
||||
mock_cookidoo_client.login.side_effect = lambda: notify_auth_data_update(
|
||||
login_tokens
|
||||
)
|
||||
client.get_recipes_in_calendar_week.return_value = [
|
||||
CookidooCalendarDay(
|
||||
id=day["id"],
|
||||
title=day["title"],
|
||||
recipes=[
|
||||
CookidooCalendarDayRecipe(
|
||||
id=recipe["id"],
|
||||
name=recipe["name"],
|
||||
total_time=recipe["total_time"],
|
||||
thumbnail=recipe["thumbnail"],
|
||||
image=recipe["image"],
|
||||
url=recipe["url"],
|
||||
)
|
||||
for recipe in day["recipes"]
|
||||
],
|
||||
)
|
||||
for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"]
|
||||
]
|
||||
yield client
|
||||
|
||||
async def _get_additional_items(*args: Any, **kwargs: Any) -> list:
|
||||
notify_auth_data_update(rotated_tokens)
|
||||
return []
|
||||
|
||||
mock_cookidoo_client.get_additional_items.side_effect = _get_additional_items
|
||||
|
||||
return _arrange
|
||||
|
||||
|
||||
@pytest.fixture(name="cookidoo_config_entry")
|
||||
@@ -98,3 +188,22 @@ def mock_cookidoo_config_entry() -> MockConfigEntry:
|
||||
entry_id="01JBVVVJ87F6G5V0QJX6HBC94T",
|
||||
unique_id=TEST_UUID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="cookidoo_config_entry_with_token")
|
||||
def mock_cookidoo_config_entry_with_token() -> MockConfigEntry:
|
||||
"""Mock a cookidoo configuration entry holding persisted OAuth2 tokens."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
minor_version=3,
|
||||
data={
|
||||
CONF_EMAIL: EMAIL,
|
||||
CONF_PASSWORD: PASSWORD,
|
||||
CONF_COUNTRY: COUNTRY,
|
||||
CONF_LANGUAGE: LANGUAGE,
|
||||
CONF_TOKEN: asdict(STALE_AUTH_DATA),
|
||||
},
|
||||
entry_id="01JBVVVJ87F6G5V0QJX6HBC94T",
|
||||
unique_id=TEST_UUID,
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
'email': 'test-email',
|
||||
'language': 'de-CH',
|
||||
'password': '**REDACTED**',
|
||||
'token': '**REDACTED**',
|
||||
}),
|
||||
'user': dict({
|
||||
'description': None,
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"""Test for calendar platform of the Cookidoo integration."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, date, datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooRequestException
|
||||
from cookidoo_api import (
|
||||
CookidooAuthData,
|
||||
CookidooAuthException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import AUTH_DATA
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
@@ -88,8 +95,10 @@ async def test_get_events(
|
||||
@pytest.mark.parametrize(
|
||||
"login_exception",
|
||||
[
|
||||
CookidooAuthException(),
|
||||
CookidooRequestException(),
|
||||
pytest.param(CookidooAuthException(), id="auth"),
|
||||
pytest.param(CookidooRequestException(), id="request"),
|
||||
pytest.param(CookidooParseException(), id="parse"),
|
||||
pytest.param(None, id="retry_fails_after_successful_login"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@@ -98,9 +107,13 @@ async def test_get_events_login_failure(
|
||||
cookidoo_config_entry: MockConfigEntry,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
login_exception: Exception,
|
||||
login_exception: Exception | None,
|
||||
) -> None:
|
||||
"""Test calendar handles login failures gracefully during event fetch."""
|
||||
"""Test calendar handles login failures gracefully during event fetch.
|
||||
|
||||
With no login exception the login succeeds and the retried fetch fails
|
||||
instead, which must be reported the same way.
|
||||
"""
|
||||
|
||||
with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]):
|
||||
await setup_integration(hass, cookidoo_config_entry)
|
||||
@@ -131,3 +144,81 @@ async def test_get_events_login_failure(
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_get_events_relogin_persists_tokens(
|
||||
hass: HomeAssistant,
|
||||
cookidoo_config_entry_with_token: MockConfigEntry,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test tokens of a calendar re-login are persisted on the config entry."""
|
||||
await setup_integration(hass, cookidoo_config_entry_with_token)
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, cookidoo_config_entry_with_token.entry_id
|
||||
)
|
||||
entity_id = entities[0].entity_id
|
||||
|
||||
week_plan = mock_cookidoo_client.get_recipes_in_calendar_week.return_value
|
||||
mock_cookidoo_client.get_recipes_in_calendar_week.side_effect = [
|
||||
CookidooAuthException(),
|
||||
week_plan,
|
||||
week_plan,
|
||||
]
|
||||
|
||||
await hass.services.async_call(
|
||||
"calendar",
|
||||
"get_events",
|
||||
{
|
||||
"start_date_time": datetime(2025, 3, 4, tzinfo=UTC),
|
||||
"end_date_time": datetime(2025, 3, 6, tzinfo=UTC),
|
||||
},
|
||||
target={"entity_id": entity_id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_get_events_persists_rotated_tokens(
|
||||
hass: HomeAssistant,
|
||||
cookidoo_config_entry_with_token: MockConfigEntry,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
notify_auth_data_update: Callable[[CookidooAuthData], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test tokens rotated during a plain calendar fetch are persisted."""
|
||||
await setup_integration(hass, cookidoo_config_entry_with_token)
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, cookidoo_config_entry_with_token.entry_id
|
||||
)
|
||||
entity_id = entities[0].entity_id
|
||||
|
||||
# The library rotates the tokens while serving the fetch, without a login
|
||||
week_plan = mock_cookidoo_client.get_recipes_in_calendar_week.return_value
|
||||
|
||||
def _rotate(week_day: date) -> list:
|
||||
notify_auth_data_update(AUTH_DATA)
|
||||
return week_plan
|
||||
|
||||
mock_cookidoo_client.get_recipes_in_calendar_week.side_effect = _rotate
|
||||
|
||||
await hass.services.async_call(
|
||||
"calendar",
|
||||
"get_events",
|
||||
{
|
||||
"start_date_time": datetime(2025, 3, 4, tzinfo=UTC),
|
||||
"end_date_time": datetime(2025, 3, 6, tzinfo=UTC),
|
||||
},
|
||||
target={"entity_id": entity_id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
mock_cookidoo_client.login.assert_not_awaited()
|
||||
assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
"""Test the Cookidoo config flow."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from cookidoo_api import CookidooAuthData
|
||||
from cookidoo_api.exceptions import (
|
||||
CookidooAuthException,
|
||||
CookidooException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cookidoo.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
|
||||
from homeassistant.const import (
|
||||
CONF_COUNTRY,
|
||||
CONF_EMAIL,
|
||||
CONF_LANGUAGE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD
|
||||
from .conftest import AUTH_DATA, COUNTRY, EMAIL, LANGUAGE, PASSWORD
|
||||
from .test_init import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -30,11 +41,45 @@ MOCK_DATA_LANGUAGE_STEP = {
|
||||
CONF_LANGUAGE: LANGUAGE,
|
||||
}
|
||||
|
||||
MOCK_TOKEN = asdict(AUTH_DATA)
|
||||
ROTATED_AUTH_DATA = CookidooAuthData(
|
||||
access_token="rotated-access-token",
|
||||
refresh_token="rotated-refresh-token",
|
||||
expires_at=1763000000.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("login_tokens", "rotated_tokens", "expected_token"),
|
||||
[
|
||||
pytest.param(AUTH_DATA, None, MOCK_TOKEN, id="tokens_from_the_login"),
|
||||
pytest.param(
|
||||
AUTH_DATA,
|
||||
ROTATED_AUTH_DATA,
|
||||
asdict(ROTATED_AUTH_DATA),
|
||||
id="tokens_rotated_during_validation",
|
||||
),
|
||||
pytest.param(None, None, {}, id="no_tokens_from_the_login"),
|
||||
],
|
||||
)
|
||||
async def test_flow_user_success(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_cookidoo_client: AsyncMock
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
arrange_validation_tokens: Callable[
|
||||
[CookidooAuthData | None, CookidooAuthData | None], None
|
||||
],
|
||||
login_tokens: CookidooAuthData | None,
|
||||
rotated_tokens: CookidooAuthData | None,
|
||||
expected_token: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test we get the user flow and create entry with success."""
|
||||
"""Test we get the user flow and create entry with success.
|
||||
|
||||
The entry is created with whatever tokens the validation ended up holding:
|
||||
the ones the login handed over, the ones a later request rotated them into,
|
||||
or none at all.
|
||||
"""
|
||||
arrange_validation_tokens(login_tokens, rotated_tokens)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
@@ -57,14 +102,57 @@ async def test_flow_user_success(
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Cookidoo"
|
||||
assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP}
|
||||
assert result["data"] == {
|
||||
**MOCK_DATA_USER_STEP,
|
||||
**MOCK_DATA_LANGUAGE_STEP,
|
||||
CONF_TOKEN: expected_token,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_flow_reauth_drops_tokens_of_a_failed_attempt(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test a retried reauth does not persist the tokens of an earlier attempt.
|
||||
|
||||
The first attempt logs in -- which hands us its tokens -- and only then
|
||||
fails, and the retry logs in without any. Those tokens belong to the
|
||||
credentials that were rejected, so they must not reach the entry.
|
||||
"""
|
||||
await setup_integration(hass, cookidoo_config_entry)
|
||||
mock_cookidoo_client.reset_mock()
|
||||
|
||||
result = await cookidoo_config_entry.start_reauth_flow(hass)
|
||||
|
||||
user_info = mock_cookidoo_client.get_user_info.return_value
|
||||
mock_cookidoo_client.get_user_info.side_effect = [
|
||||
CookidooRequestException(),
|
||||
user_info,
|
||||
]
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "wrong-email", CONF_PASSWORD: "wrong-password"},
|
||||
)
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
# The retried login yields no tokens, so nothing overwrites the stale pair
|
||||
mock_cookidoo_client.login.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"},
|
||||
)
|
||||
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert cookidoo_config_entry.data[CONF_TOKEN] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raise_error", "text_error"),
|
||||
[
|
||||
(CookidooRequestException(), "cannot_connect"),
|
||||
(CookidooParseException(), "cannot_connect"),
|
||||
(CookidooAuthException(), "invalid_auth"),
|
||||
(CookidooException(), "unknown"),
|
||||
(IndexError(), "unknown"),
|
||||
@@ -73,6 +161,7 @@ async def test_flow_user_success(
|
||||
async def test_flow_user_init_data_unknown_error_and_recover_on_step_1(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
login_success: Callable[[], None],
|
||||
raise_error: Exception,
|
||||
text_error: str,
|
||||
) -> None:
|
||||
@@ -91,7 +180,7 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1(
|
||||
assert result["errors"]["base"] == text_error
|
||||
|
||||
# Recover
|
||||
mock_cookidoo_client.login.side_effect = None
|
||||
mock_cookidoo_client.login.side_effect = login_success
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=MOCK_DATA_USER_STEP,
|
||||
@@ -108,13 +197,18 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].title == "Cookidoo"
|
||||
|
||||
assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP}
|
||||
assert result["data"] == {
|
||||
**MOCK_DATA_USER_STEP,
|
||||
**MOCK_DATA_LANGUAGE_STEP,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raise_error", "text_error"),
|
||||
[
|
||||
(CookidooRequestException(), "cannot_connect"),
|
||||
(CookidooParseException(), "cannot_connect"),
|
||||
(CookidooAuthException(), "invalid_auth"),
|
||||
(CookidooException(), "unknown"),
|
||||
(IndexError(), "unknown"),
|
||||
@@ -158,7 +252,11 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_2(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].title == "Cookidoo"
|
||||
|
||||
assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP}
|
||||
assert result["data"] == {
|
||||
**MOCK_DATA_USER_STEP,
|
||||
**MOCK_DATA_LANGUAGE_STEP,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
async def test_flow_user_init_data_already_configured(
|
||||
@@ -224,6 +322,7 @@ async def test_flow_reconfigure_success(
|
||||
CONF_PASSWORD: "new-password",
|
||||
CONF_COUNTRY: "DE",
|
||||
CONF_LANGUAGE: "de-DE",
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
@@ -232,6 +331,7 @@ async def test_flow_reconfigure_success(
|
||||
("raise_error", "text_error"),
|
||||
[
|
||||
(CookidooRequestException(), "cannot_connect"),
|
||||
(CookidooParseException(), "cannot_connect"),
|
||||
(CookidooException(), "unknown"),
|
||||
(IndexError(), "unknown"),
|
||||
],
|
||||
@@ -240,6 +340,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1(
|
||||
hass: HomeAssistant,
|
||||
cookidoo_config_entry: AsyncMock,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
login_success: Callable[[], None],
|
||||
raise_error: Exception,
|
||||
text_error: str,
|
||||
) -> None:
|
||||
@@ -263,7 +364,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1(
|
||||
assert result["errors"]["base"] == text_error
|
||||
|
||||
# Recover
|
||||
mock_cookidoo_client.login.side_effect = None
|
||||
mock_cookidoo_client.login.side_effect = login_success
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"},
|
||||
@@ -283,6 +384,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1(
|
||||
**MOCK_DATA_USER_STEP,
|
||||
CONF_COUNTRY: "DE",
|
||||
CONF_LANGUAGE: "de-DE",
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
@@ -291,6 +393,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1(
|
||||
("raise_error", "text_error"),
|
||||
[
|
||||
(CookidooRequestException(), "cannot_connect"),
|
||||
(CookidooParseException(), "cannot_connect"),
|
||||
(CookidooException(), "unknown"),
|
||||
(IndexError(), "unknown"),
|
||||
],
|
||||
@@ -343,6 +446,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2(
|
||||
**MOCK_DATA_USER_STEP,
|
||||
CONF_COUNTRY: "DE",
|
||||
CONF_LANGUAGE: "de-DE",
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
@@ -401,6 +505,7 @@ async def test_flow_reauth(
|
||||
CONF_PASSWORD: "new-password",
|
||||
CONF_COUNTRY: COUNTRY,
|
||||
CONF_LANGUAGE: LANGUAGE,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
@@ -409,6 +514,7 @@ async def test_flow_reauth(
|
||||
("raise_error", "text_error"),
|
||||
[
|
||||
(CookidooRequestException(), "cannot_connect"),
|
||||
(CookidooParseException(), "cannot_connect"),
|
||||
(CookidooAuthException(), "invalid_auth"),
|
||||
(CookidooException(), "unknown"),
|
||||
(IndexError(), "unknown"),
|
||||
@@ -418,6 +524,7 @@ async def test_flow_reauth_error_and_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry: MockConfigEntry,
|
||||
login_success: Callable[[], None],
|
||||
raise_error,
|
||||
text_error,
|
||||
) -> None:
|
||||
@@ -438,7 +545,7 @@ async def test_flow_reauth_error_and_recover(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": text_error}
|
||||
|
||||
mock_cookidoo_client.login.side_effect = None
|
||||
mock_cookidoo_client.login.side_effect = login_success
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"},
|
||||
@@ -451,6 +558,7 @@ async def test_flow_reauth_error_and_recover(
|
||||
CONF_PASSWORD: "new-password",
|
||||
CONF_COUNTRY: COUNTRY,
|
||||
CONF_LANGUAGE: LANGUAGE,
|
||||
CONF_TOKEN: MOCK_TOKEN,
|
||||
}
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
"""Unit tests for the cookidoo integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooRequestException
|
||||
from cookidoo_api import (
|
||||
CookidooAuthData,
|
||||
CookidooAuthException,
|
||||
CookidooParseException,
|
||||
CookidooRequestException,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cookidoo.const import DOMAIN
|
||||
@@ -12,15 +21,24 @@ from homeassistant.const import (
|
||||
CONF_EMAIL,
|
||||
CONF_LANGUAGE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID
|
||||
from .conftest import (
|
||||
AUTH_DATA,
|
||||
COUNTRY,
|
||||
EMAIL,
|
||||
LANGUAGE,
|
||||
PASSWORD,
|
||||
STALE_AUTH_DATA,
|
||||
TEST_UUID,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_cookidoo_client")
|
||||
@@ -45,6 +63,7 @@ async def test_load_unload(
|
||||
[
|
||||
(CookidooRequestException, ConfigEntryState.SETUP_RETRY),
|
||||
(CookidooAuthException, ConfigEntryState.SETUP_ERROR),
|
||||
(CookidooParseException, ConfigEntryState.SETUP_RETRY),
|
||||
],
|
||||
)
|
||||
async def test_init_failure(
|
||||
@@ -85,11 +104,27 @@ async def test_config_entry_not_ready(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("login_exception", "status"),
|
||||
("login_exception", "status", "reason"),
|
||||
[
|
||||
(None, ConfigEntryState.LOADED),
|
||||
(CookidooRequestException(), ConfigEntryState.SETUP_RETRY),
|
||||
(CookidooAuthException(), ConfigEntryState.SETUP_ERROR),
|
||||
pytest.param(None, ConfigEntryState.LOADED, None, id="relogin_succeeds"),
|
||||
pytest.param(
|
||||
CookidooRequestException(),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
"Failed to connect to server, try again later",
|
||||
id="request",
|
||||
),
|
||||
pytest.param(
|
||||
CookidooAuthException(),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
"Authentication failed for test-email, check your email and password",
|
||||
id="auth",
|
||||
),
|
||||
pytest.param(
|
||||
CookidooParseException(),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
"Failed to connect to server, try again later",
|
||||
id="parse",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_config_entry_not_ready_auth_error(
|
||||
@@ -98,6 +133,7 @@ async def test_config_entry_not_ready_auth_error(
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
login_exception: Exception | None,
|
||||
status: ConfigEntryState,
|
||||
reason: str | None,
|
||||
) -> None:
|
||||
"""Test config entry recovery when data fetch hits an auth error.
|
||||
|
||||
@@ -121,6 +157,8 @@ async def test_config_entry_not_ready_auth_error(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert cookidoo_config_entry.state is status
|
||||
# A translated reason proves the exception was handled rather than escaping
|
||||
assert cookidoo_config_entry.reason == reason
|
||||
|
||||
|
||||
MOCK_CONFIG_ENTRY_MIGRATION = {
|
||||
@@ -344,6 +382,20 @@ async def test_migration_from_partial_duplicate_unique_ids(
|
||||
"old_ciam_sub_uuid",
|
||||
CookidooAuthException,
|
||||
),
|
||||
(
|
||||
1,
|
||||
1,
|
||||
MOCK_CONFIG_ENTRY_MIGRATION,
|
||||
None,
|
||||
CookidooParseException,
|
||||
),
|
||||
(
|
||||
1,
|
||||
2,
|
||||
MOCK_CONFIG_ENTRY_MIGRATION,
|
||||
"old_ciam_sub_uuid",
|
||||
CookidooParseException,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_migration_from_with_error(
|
||||
@@ -356,6 +408,7 @@ async def test_migration_from_with_error(
|
||||
unique_id,
|
||||
login_exception: Exception,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test different expected migration paths but with connection issues."""
|
||||
# Migration can fail due to connection issues as we have to fetch the uuid
|
||||
@@ -406,6 +459,8 @@ async def test_migration_from_with_error(
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
# A handled failure, rather than the exception escaping async_migrate_entry
|
||||
assert "Could not migrate config entry" in caplog.text
|
||||
|
||||
assert entity_registry.async_is_registered(
|
||||
entity_registry.entities.get_entity_id(
|
||||
@@ -434,3 +489,103 @@ async def test_migration_from_with_error(
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_login_persists_tokens(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the OAuth2 tokens of a credential login are stored on the entry."""
|
||||
await setup_integration(hass, cookidoo_config_entry)
|
||||
|
||||
assert cookidoo_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_cookidoo_client.login.assert_awaited_once()
|
||||
assert cookidoo_config_entry.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
|
||||
async def test_tokens_persisted_when_user_info_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test tokens of a successful login survive a failing user info fetch."""
|
||||
mock_cookidoo_client.get_user_info.side_effect = CookidooRequestException()
|
||||
|
||||
await setup_integration(hass, cookidoo_config_entry)
|
||||
|
||||
assert cookidoo_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
# Without this the next attempt would replay the whole login
|
||||
assert cookidoo_config_entry.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
|
||||
async def test_stored_tokens_skip_login(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry_with_token: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the persisted OAuth2 tokens are reused instead of logging in again."""
|
||||
await setup_integration(hass, cookidoo_config_entry_with_token)
|
||||
|
||||
assert cookidoo_config_entry_with_token.state is ConfigEntryState.LOADED
|
||||
mock_cookidoo_client.apply_auth_data.assert_called_once_with(STALE_AUTH_DATA)
|
||||
mock_cookidoo_client.login.assert_not_awaited()
|
||||
assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(STALE_AUTH_DATA)
|
||||
|
||||
|
||||
async def test_expired_tokens_fall_back_to_login(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry_with_token: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test expired persisted tokens fall back to a credential login."""
|
||||
user_info = mock_cookidoo_client.get_user_info.return_value
|
||||
mock_cookidoo_client.get_user_info.side_effect = [
|
||||
CookidooAuthException(),
|
||||
user_info,
|
||||
]
|
||||
|
||||
await setup_integration(hass, cookidoo_config_entry_with_token)
|
||||
|
||||
assert cookidoo_config_entry_with_token.state is ConfigEntryState.LOADED
|
||||
mock_cookidoo_client.login.assert_awaited_once()
|
||||
assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subscription_side_effect",
|
||||
[
|
||||
pytest.param(None, id="update_succeeds"),
|
||||
pytest.param(CookidooRequestException(), id="later_call_fails"),
|
||||
],
|
||||
)
|
||||
async def test_tokens_rotated_during_update_are_persisted(
|
||||
hass: HomeAssistant,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
cookidoo_config_entry_with_token: MockConfigEntry,
|
||||
notify_auth_data_update: Callable[[CookidooAuthData], None],
|
||||
subscription_side_effect: Exception | None,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test tokens the library rotates while serving an update are persisted.
|
||||
|
||||
The refresh a request performs on its own rotates the refresh token with it,
|
||||
so the new pair has to reach the entry whether the update as a whole went on
|
||||
to succeed or a later call failed.
|
||||
"""
|
||||
await setup_integration(hass, cookidoo_config_entry_with_token)
|
||||
|
||||
ingredient_items = mock_cookidoo_client.get_ingredient_items.return_value
|
||||
|
||||
def _rotate() -> list:
|
||||
notify_auth_data_update(AUTH_DATA)
|
||||
return ingredient_items
|
||||
|
||||
mock_cookidoo_client.get_ingredient_items.side_effect = _rotate
|
||||
mock_cookidoo_client.get_active_subscription.side_effect = subscription_side_effect
|
||||
|
||||
freezer.tick(timedelta(seconds=90))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Test for todo platform of the Cookidoo integration."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import asdict
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from cookidoo_api import (
|
||||
CookidooAdditionalItem,
|
||||
CookidooAuthData,
|
||||
CookidooIngredientItem,
|
||||
CookidooRequestException,
|
||||
)
|
||||
@@ -21,12 +23,13 @@ from homeassistant.components.todo import (
|
||||
TodoServices,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import AUTH_DATA
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
@@ -291,3 +294,31 @@ async def test_delete_additional_items_exception(
|
||||
target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_failed_action_persists_rotated_tokens(
|
||||
hass: HomeAssistant,
|
||||
cookidoo_config_entry_with_token: MockConfigEntry,
|
||||
mock_cookidoo_client: AsyncMock,
|
||||
notify_auth_data_update: Callable[[CookidooAuthData], None],
|
||||
) -> None:
|
||||
"""Test tokens rotated during a failing todo action are still persisted."""
|
||||
await setup_integration(hass, cookidoo_config_entry_with_token)
|
||||
|
||||
# The library rotates the tokens while serving the request, which then fails
|
||||
def _rotate_then_fail(uids: list[str]) -> None:
|
||||
notify_auth_data_update(AUTH_DATA)
|
||||
raise CookidooRequestException
|
||||
|
||||
mock_cookidoo_client.remove_additional_items.side_effect = _rotate_then_fail
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.REMOVE_ITEM,
|
||||
service_data={ATTR_ITEM: "unique_id_tomaten"},
|
||||
target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA)
|
||||
|
||||
Reference in New Issue
Block a user