diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 48c37c64db0..67095422e65 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -2,41 +2,29 @@ from __future__ import annotations -from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options +import logging -from homeassistant.const import ( - CONF_COUNTRY, - CONF_EMAIL, - CONF_LANGUAGE, - CONF_PASSWORD, - Platform, -) +from cookidoo_api import CookidooAuthException, CookidooRequestException + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry as dr, entity_registry as er +from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .helpers import cookidoo_from_config_entry PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Set up Cookidoo from a config entry.""" - localizations = await get_localization_options( - country=entry.data[CONF_COUNTRY].lower(), - language=entry.data[CONF_LANGUAGE], + coordinator = CookidooDataUpdateCoordinator( + hass, await cookidoo_from_config_entry(hass, entry), entry ) - - cookidoo = Cookidoo( - async_get_clientsession(hass), - CookidooConfig( - email=entry.data[CONF_EMAIL], - password=entry.data[CONF_PASSWORD], - localization=localizations[0], - ), - ) - - coordinator = CookidooDataUpdateCoordinator(hass, cookidoo, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -49,3 +37,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: CookidooConfigEntry +) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1 and config_entry.minor_version == 1: + # Add the unique uuid + cookidoo = await cookidoo_from_config_entry(hass, config_entry) + + try: + auth_data = await cookidoo.login() + except (CookidooRequestException, CookidooAuthException) as e: + _LOGGER.error( + "Could not migrate config config_entry: %s", + str(e), + ) + return False + + unique_id = auth_data.sub + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + device_registry.async_update_device( + dev.id, new_identifiers={(DOMAIN, unique_id)} + ) + for ent in entity_entries: + assert ent.config_entry_id + entity_registry.async_update_entity( + ent.entity_id, + new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id), + ) + + hass.config_entries.async_update_entry( + config_entry, unique_id=auth_data.sub, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py index 2a20a156db4..b292a7309ba 100644 --- a/homeassistant/components/cookidoo/button.py +++ b/homeassistant/components/cookidoo/button.py @@ -56,7 +56,8 @@ class CookidooButton(CookidooBaseEntity, ButtonEntity): """Initialize cookidoo button.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 80487ed757f..71ad3015730 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -7,9 +7,7 @@ import logging from typing import Any from cookidoo_api import ( - Cookidoo, CookidooAuthException, - CookidooConfig, CookidooRequestException, get_country_options, get_localization_options, @@ -23,7 +21,6 @@ from homeassistant.config_entries import ( ConfigFlowResult, ) from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, @@ -35,6 +32,7 @@ from homeassistant.helpers.selector import ( ) from .const import DOMAIN +from .helpers import cookidoo_from_config_data _LOGGER = logging.getLogger(__name__) @@ -57,10 +55,14 @@ AUTH_DATA_SCHEMA = { class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Cookidoo.""" + VERSION = 1 + MINOR_VERSION = 2 + COUNTRY_DATA_SCHEMA: dict LANGUAGE_DATA_SCHEMA: dict user_input: dict[str, Any] + user_uuid: str async def async_step_reconfigure( self, user_input: dict[str, Any] @@ -78,8 +80,11 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None and not ( errors := await self.validate_input(user_input) ): + await self.async_set_unique_id(self.user_uuid) if self.source == SOURCE_USER: - self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() self.user_input = user_input return await self.async_step_language() await self.generate_country_schema() @@ -153,10 +158,8 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): if not ( errors := await self.validate_input({**reauth_entry.data, **user_input}) ): - if user_input[CONF_EMAIL] != reauth_entry.data[CONF_EMAIL]: - self._async_abort_entries_match( - {CONF_EMAIL: user_input[CONF_EMAIL]} - ) + 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 ) @@ -220,21 +223,10 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): await get_localization_options(country=data_input[CONF_COUNTRY].lower()) )[0].language # Pick any language to test login - localizations = await get_localization_options( - country=data_input[CONF_COUNTRY].lower(), - language=data_input[CONF_LANGUAGE], - ) - - cookidoo = Cookidoo( - async_get_clientsession(self.hass), - CookidooConfig( - email=data_input[CONF_EMAIL], - password=data_input[CONF_PASSWORD], - localization=localizations[0], - ), - ) + cookidoo = await cookidoo_from_config_data(self.hass, data_input) try: - await cookidoo.login() + auth_data = await cookidoo.login() + self.user_uuid = auth_data.sub if language_input: await cookidoo.get_additional_items() except CookidooRequestException: diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py index 5c8f3ec8441..97ebb384ecb 100644 --- a/homeassistant/components/cookidoo/entity.py +++ b/homeassistant/components/cookidoo/entity.py @@ -21,10 +21,12 @@ class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]): """Initialize the entity.""" super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name="Cookidoo", - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, manufacturer="Vorwerk International & Co. KmG", model="Cookidoo - Thermomix® recipe portal", ) diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py new file mode 100644 index 00000000000..199abb2e05d --- /dev/null +++ b/homeassistant/components/cookidoo/helpers.py @@ -0,0 +1,37 @@ +"""Helpers for cookidoo.""" + +from typing import Any + +from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options + +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import CookidooConfigEntry + + +async def cookidoo_from_config_data( + hass: HomeAssistant, data: dict[str, Any] +) -> Cookidoo: + """Build cookidoo from config data.""" + localizations = await get_localization_options( + country=data[CONF_COUNTRY].lower(), + language=data[CONF_LANGUAGE], + ) + + return Cookidoo( + async_get_clientsession(hass), + CookidooConfig( + email=data[CONF_EMAIL], + password=data[CONF_PASSWORD], + localization=localizations[0], + ), + ) + + +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)) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 83cc182be16..3f786fe937a 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -44,7 +44,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The user identifier does not match the previous identifier" } }, "entity": { diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py index 4a70dadc65a..3d5264f4e01 100644 --- a/homeassistant/components/cookidoo/todo.py +++ b/homeassistant/components/cookidoo/todo.py @@ -52,7 +52,8 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_ingredients" @property def todo_items(self) -> list[TodoItem]: @@ -112,7 +113,8 @@ class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity): def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items" + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_additional_items" @property def todo_items(self) -> list[TodoItem]: diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 66c2064eb3a..a14bc285379 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -21,6 +21,8 @@ PASSWORD = "test-password" COUNTRY = "CH" LANGUAGE = "de-CH" +TEST_UUID = "sub_uuid" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -34,16 +36,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_cookidoo_client() -> Generator[AsyncMock]: """Mock a Cookidoo client.""" - with ( - patch( - "homeassistant.components.cookidoo.Cookidoo", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.cookidoo.config_flow.Cookidoo", - new=mock_client, - ), - ): + with patch( + "homeassistant.components.cookidoo.helpers.Cookidoo", + autospec=True, + ) as mock_client: client = mock_client.return_value client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"}) client.get_ingredient_items.return_value = [ @@ -58,7 +54,9 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] - client.login.return_value = None + client.login.return_value = CookidooAuthResponse( + **load_json_object_fixture("login.json", DOMAIN) + ) yield client @@ -67,6 +65,8 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: """Mock cookidoo configuration entry.""" return MockConfigEntry( domain=DOMAIN, + version=1, + minor_version=2, data={ CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD, @@ -74,4 +74,5 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: CONF_LANGUAGE: LANGUAGE, }, entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, ) diff --git a/tests/components/cookidoo/fixtures/login.json b/tests/components/cookidoo/fixtures/login.json new file mode 100644 index 00000000000..e7bd6e8716c --- /dev/null +++ b/tests/components/cookidoo/fixtures/login.json @@ -0,0 +1,7 @@ +{ + "access_token": "eyJhbGci", + "expires_in": 43199, + "refresh_token": "eyJhbGciOiJSUzI1NiI", + "token_type": "bearer", + "sub": "sub_uuid" +} diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr index 60f9e95bee7..a6223059aa1 100644 --- a/tests/components/cookidoo/snapshots/test_button.ambr +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'todo_clear', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_todo_clear', + 'unique_id': 'sub_uuid_todo_clear', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr index 965cbb0adde..be641432929 100644 --- a/tests/components/cookidoo/snapshots/test_todo.ambr +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'additional_item_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items', + 'unique_id': 'sub_uuid_additional_items', 'unit_of_measurement': None, }) # --- @@ -75,7 +75,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'ingredient_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_ingredients', + 'unique_id': 'sub_uuid_ingredients', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 0057bb3767e..069442517a0 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -200,7 +200,12 @@ async def test_flow_reconfigure_success( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + user_input={ + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, ) assert result["type"] is FlowResultType.FORM @@ -215,6 +220,8 @@ async def test_flow_reconfigure_success( assert result["reason"] == "reconfigure_successful" assert cookidoo_config_entry.data == { **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", } @@ -340,6 +347,35 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( assert len(hass.config_entries.async_entries()) == 1 +async def test_flow_reconfigure_id_mismatch( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort when the new config is not for the same user.""" + + cookidoo_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" + ) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + async def test_flow_reauth( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, @@ -419,46 +455,26 @@ async def test_flow_reauth_error_and_recover( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.parametrize( - ("new_email", "saved_email", "result_reason"), - [ - (EMAIL, EMAIL, "reauth_successful"), - ("another-email", EMAIL, "already_configured"), - ], -) -async def test_flow_reauth_init_data_already_configured( +async def test_flow_reauth_id_mismatch( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, cookidoo_config_entry: MockConfigEntry, - new_email: str, - saved_email: str, - result_reason: str, ) -> None: - """Test we abort user data set when entry is already configured.""" + """Test we abort when the new auth is not for the same user.""" cookidoo_config_entry.add_to_hass(hass) - - another_cookidoo_config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_EMAIL: "another-email", - CONF_PASSWORD: PASSWORD, - CONF_COUNTRY: COUNTRY, - CONF_LANGUAGE: LANGUAGE, - }, + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" ) - another_cookidoo_config_entry.add_to_hass(hass) - result = await cookidoo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_EMAIL: new_email, CONF_PASSWORD: PASSWORD}, + {CONF_EMAIL: "new-email", CONF_PASSWORD: PASSWORD}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == result_reason - assert cookidoo_config_entry.data[CONF_EMAIL] == saved_email + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py index b1b9b880526..e97bf93bb21 100644 --- a/tests/components/cookidoo/test_init.py +++ b/tests/components/cookidoo/test_init.py @@ -7,9 +7,18 @@ import pytest from homeassistant.components.cookidoo.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + 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 tests.common import MockConfigEntry @@ -100,3 +109,229 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert cookidoo_config_entry.state is status + + +MOCK_CONFIG_ENTRY_MIGRATION = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, +} + +OLD_ENTRY_ID = "OLD_OLD_ENTRY_ID" + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + ), + (1, 2, MOCK_CONFIG_ENTRY_MIGRATION, TEST_UUID), + ], +) +async def test_migration_from( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version}", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + + # Check change in config entry and verify most recent version + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == TEST_UUID + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{TEST_UUID}_todo_clear", + ) + ) + ) + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + "login_exception", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooRequestException, + ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooAuthException, + ), + ], +) +async def test_migration_from_with_error( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + login_exception: Exception, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths but with connection issues.""" + # Migration can fail due to connection issues as we have to fetch the uuid + mock_cookidoo_client.login.side_effect = login_exception + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version} with login exception '{login_exception}'", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{OLD_ENTRY_ID}_todo_clear", + ) + ) + )