diff --git a/CODEOWNERS b/CODEOWNERS index 5df8a86a876..779f6f8b6c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1358,8 +1358,8 @@ build.json @home-assistant/supervisor /tests/components/ring/ @sdb9696 /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund -/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck -/tests/components/rituals_perfume_genie/ @milanmeu @frenck +/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm +/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi /homeassistant/components/roborock/ @Lash-L @allenporter diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index e920c2426fe..f2f1fcccfdc 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -1,20 +1,23 @@ """The Rituals Perfume Genie integration.""" import asyncio +import logging -import aiohttp -from pyrituals import Account, Diffuser +from aiohttp import ClientError, ClientResponseError +from pyrituals import Account, AuthenticationException, Diffuser from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL from .coordinator import RitualsDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -26,12 +29,38 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rituals Perfume Genie from a config entry.""" + # Initiate reauth for old config entries which don't have username / password in the entry data + if CONF_EMAIL not in entry.data or CONF_PASSWORD not in entry.data: + raise ConfigEntryAuthFailed("Missing credentials") + session = async_get_clientsession(hass) - account = Account(session=session, account_hash=entry.data[ACCOUNT_HASH]) + + account = Account( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=session, + ) try: + # Authenticate first so API token/cookies are available for subsequent calls + await account.authenticate() account_devices = await account.get_devices() - except aiohttp.ClientError as err: + + except AuthenticationException as err: + # Credentials invalid/expired -> raise AuthFailed to trigger reauth flow + + raise ConfigEntryAuthFailed(err) from err + + except ClientResponseError as err: + _LOGGER.debug( + "HTTP error during Rituals setup: status=%s, url=%s, headers=%s", + err.status, + err.request_info, + dict(err.headers or {}), + ) + raise ConfigEntryNotReady from err + + except ClientError as err: raise ConfigEntryNotReady from err # Migrate old unique_ids to the new format @@ -45,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create a coordinator for each diffuser coordinators = { diffuser.hublot: RitualsDataUpdateCoordinator( - hass, entry, diffuser, update_interval + hass, entry, account, diffuser, update_interval ) for diffuser in account_devices } @@ -106,3 +135,14 @@ def async_migrate_entities_unique_ids( registry_entry.entity_id, new_unique_id=f"{diffuser.hublot}-{new_unique_id}", ) + + +# Migration helpers for API v2 +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate config entry to version 2: drop legacy ACCOUNT_HASH and bump version.""" + if entry.version < 2: + data = dict(entry.data) + data.pop(ACCOUNT_HASH, None) + hass.config_entries.async_update_entry(entry, data=data, version=2) + return True + return True diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py index f6736ab78e4..ee7e57c0fd8 100644 --- a/homeassistant/components/rituals_perfume_genie/config_flow.py +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -2,10 +2,10 @@ from __future__ import annotations -import logging -from typing import Any +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any -from aiohttp import ClientResponseError +from aiohttp import ClientError from pyrituals import Account, AuthenticationException import voluptuous as vol @@ -13,9 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ACCOUNT_HASH, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN DATA_SCHEMA = vol.Schema( { @@ -28,39 +26,88 @@ DATA_SCHEMA = vol.Schema( class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Rituals Perfume Genie.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) - - errors = {} - - session = async_get_clientsession(self.hass) - account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session) - - try: - await account.authenticate() - except ClientResponseError: - _LOGGER.exception("Unexpected response") - errors["base"] = "cannot_connect" - except AuthenticationException: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(account.email) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=account.email, - data={ACCOUNT_HASH: account.account_hash}, + errors: dict[str, str] = {} + if user_input is not None: + session = async_get_clientsession(self.hass) + account = Account( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=session, ) + try: + await account.authenticate() + except AuthenticationException: + errors["base"] = "invalid_auth" + except ClientError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data=user_input, + ) + return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication with Rituals.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Form to log in again.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + + if TYPE_CHECKING: + assert reauth_entry.unique_id is not None + + if user_input: + session = async_get_clientsession(self.hass) + account = Account( + email=reauth_entry.unique_id, + password=user_input[CONF_PASSWORD], + session=session, + ) + + try: + await account.authenticate() + except AuthenticationException: + errors["base"] = "invalid_auth" + except ClientError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data={ + CONF_EMAIL: reauth_entry.unique_id, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + reauth_entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index 45428ced9d2..b0f1fe2f2b4 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -4,6 +4,7 @@ from datetime import timedelta DOMAIN = "rituals_perfume_genie" +# Old (API V1) ACCOUNT_HASH = "account_hash" # The API provided by Rituals is currently rate limited to 30 requests diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index bbcb24b3e65..8513c994320 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -3,11 +3,13 @@ from datetime import timedelta import logging -from pyrituals import Diffuser +from aiohttp import ClientError, ClientResponseError +from pyrituals import Account, AuthenticationException, Diffuser from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -23,10 +25,12 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): self, hass: HomeAssistant, config_entry: ConfigEntry, + account: Account, diffuser: Diffuser, update_interval: timedelta, ) -> None: """Initialize global Rituals Perfume Genie data updater.""" + self.account = account self.diffuser = diffuser super().__init__( hass, @@ -37,5 +41,36 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): ) async def _async_update_data(self) -> None: - """Fetch data from Rituals.""" - await self.diffuser.update_data() + """Fetch data from Rituals, with one silent re-auth on 401. + + If silent re-auth also fails, raise ConfigEntryAuthFailed to trigger reauth flow. + Other HTTP/network errors are wrapped in UpdateFailed so HA can retry. + """ + try: + await self.diffuser.update_data() + except (AuthenticationException, ClientResponseError) as err: + # Treat 401/403 like AuthenticationException → one silent re-auth, single retry + if isinstance(err, ClientResponseError) and (status := err.status) not in ( + 401, + 403, + ): + # Non-auth HTTP error → let HA retry + raise UpdateFailed(f"HTTP {status}") from err + + self.logger.debug( + "Auth issue detected (%r). Attempting silent re-auth.", err + ) + try: + await self.account.authenticate() + await self.diffuser.update_data() + except AuthenticationException as err2: + # Credentials invalid → trigger HA reauth + raise ConfigEntryAuthFailed from err2 + except ClientResponseError as err2: + # Still HTTP auth errors after refresh → trigger HA reauth + if err2.status in (401, 403): + raise ConfigEntryAuthFailed from err2 + raise UpdateFailed(f"HTTP {err2.status}") from err2 + except ClientError as err: + # Network issues (timeouts, DNS, etc.) + raise UpdateFailed(f"Network error: {err!r}") from err diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 114491d9122..0acee2ae604 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -1,10 +1,10 @@ { "domain": "rituals_perfume_genie", "name": "Rituals Perfume Genie", - "codeowners": ["@milanmeu", "@frenck"], + "codeowners": ["@milanmeu", "@frenck", "@quebulm"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", "iot_class": "cloud_polling", "loggers": ["pyrituals"], - "requirements": ["pyrituals==0.0.6"] + "requirements": ["pyrituals==0.0.7"] } diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index d897b87908e..a2b390f4d2e 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +10,12 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter the correct password." + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", diff --git a/requirements_all.txt b/requirements_all.txt index dda5bf557a9..0a423b6c131 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2351,7 +2351,7 @@ pyrepetierng==0.1.0 pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie -pyrituals==0.0.6 +pyrituals==0.0.7 # homeassistant.components.thread pyroute2==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc95f1389be..30c5c521bce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1980,7 +1980,7 @@ pyrate-limiter==3.9.0 pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie -pyrituals==0.0.6 +pyrituals==0.0.7 # homeassistant.components.thread pyroute2==0.7.5 diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py index 044582c5735..76f7ad1545c 100644 --- a/tests/components/rituals_perfume_genie/common.py +++ b/tests/components/rituals_perfume_genie/common.py @@ -4,8 +4,9 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN +from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_json_object_fixture @@ -17,7 +18,11 @@ def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConf domain=DOMAIN, title="name@example.com", unique_id=unique_id, - data={ACCOUNT_HASH: "an_account_hash"}, + data={ + CONF_EMAIL: "test@rituals.com", + CONF_PASSWORD: "test-password", + }, + version=2, entry_id=entry_id, ) @@ -90,13 +95,15 @@ async def init_integration( """Initialize the Rituals Perfume Genie integration with the given Config Entry and Diffuser list.""" mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.rituals_perfume_genie.Account.get_devices", - return_value=mock_diffusers, - ): + "homeassistant.components.rituals_perfume_genie.Account" + ) as mock_account_cls: + mock_account = mock_account_cls.return_value + mock_account.authenticate = AsyncMock() + mock_account.get_devices = AsyncMock(return_value=mock_diffusers) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.entry_id in hass.data[DOMAIN] assert hass.data[DOMAIN] - - await hass.async_block_till_done() diff --git a/tests/components/rituals_perfume_genie/conftest.py b/tests/components/rituals_perfume_genie/conftest.py new file mode 100644 index 00000000000..aedd8ecdb1f --- /dev/null +++ b/tests/components/rituals_perfume_genie/conftest.py @@ -0,0 +1,64 @@ +"""Fixtures for Rituals Perfume Genie tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.rituals_perfume_genie import ACCOUNT_HASH, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import TEST_EMAIL, TEST_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.rituals_perfume_genie.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_rituals_account() -> Generator[AsyncMock]: + """Mock Rituals Account.""" + with ( + patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account", + autospec=True, + ) as mock_account_cls, + patch( + "homeassistant.components.rituals_perfume_genie.Account", + new=mock_account_cls, + ), + ): + mock_account = mock_account_cls.return_value + yield mock_account + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock Rituals Account.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_EMAIL, + data={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}, + title=TEST_EMAIL, + version=2, + ) + + +@pytest.fixture +def old_mock_config_entry() -> MockConfigEntry: + """Mock Rituals Account.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_EMAIL, + data={ACCOUNT_HASH: "old_hash_should_be_removed"}, + title=TEST_EMAIL, + version=1, + ) diff --git a/tests/components/rituals_perfume_genie/const.py b/tests/components/rituals_perfume_genie/const.py new file mode 100644 index 00000000000..eeef87c711e --- /dev/null +++ b/tests/components/rituals_perfume_genie/const.py @@ -0,0 +1,4 @@ +"""Constants for rituals_perfume_genie tests.""" + +TEST_EMAIL = "test@rituals.com" +TEST_PASSWORD = "test-password" diff --git a/tests/components/rituals_perfume_genie/test_config_flow.py b/tests/components/rituals_perfume_genie/test_config_flow.py index 6c0a09a8303..52498afb1db 100644 --- a/tests/components/rituals_perfume_genie/test_config_flow.py +++ b/tests/components/rituals_perfume_genie/test_config_flow.py @@ -1,126 +1,213 @@ """Test the Rituals Perfume Genie config flow.""" -from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError +from aiohttp import ClientError from pyrituals import AuthenticationException +import pytest -from homeassistant import config_entries -from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN +from homeassistant.components.rituals_perfume_genie.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -TEST_EMAIL = "rituals@example.com" -VALID_PASSWORD = "passw0rd" -WRONG_PASSWORD = "wrong-passw0rd" +from .const import TEST_EMAIL, TEST_PASSWORD + +from tests.common import MockConfigEntry -def _mock_account(*_): - account = MagicMock() - account.authenticate = AsyncMock() - account.account_hash = "any" - account.email = TEST_EMAIL - return account - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user_flow_success( + hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful user flow setup.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" + assert result["errors"] == {} - with ( - patch( - "homeassistant.components.rituals_perfume_genie.config_flow.Account", - side_effect=_mock_account, - ), - patch( - "homeassistant.components.rituals_perfume_genie.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: TEST_EMAIL, - CONF_PASSWORD: VALID_PASSWORD, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == TEST_EMAIL - assert isinstance(result2["data"][ACCOUNT_HASH], str) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_EMAIL assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationException, "invalid_auth"), + (ClientError, "cannot_connect"), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_rituals_account: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test user flow with different errors.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} + ) + mock_rituals_account.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, ) - with patch( - "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", - side_effect=AuthenticationException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: TEST_EMAIL, - CONF_PASSWORD: WRONG_PASSWORD, - }, - ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + mock_rituals_account.authenticate.side_effect = None - -async def test_form_auth_exception(hass: HomeAssistant) -> None: - """Test we handle auth exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, ) - with patch( - "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: TEST_EMAIL, - CONF_PASSWORD: VALID_PASSWORD, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" +async def test_duplicate_entry( + hass: HomeAssistant, + mock_rituals_account: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user flow with invalid credentials.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", - side_effect=ClientResponseError( - None, None, status=HTTPStatus.INTERNAL_SERVER_ERROR - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: TEST_EMAIL, - CONF_PASSWORD: VALID_PASSWORD, - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_rituals_account: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reauth flow (updating credentials).""" + mock_config_entry.add_to_hass(hass) + + result = await mock_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_PASSWORD: "new_correct_password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_PASSWORD] == "new_correct_password" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationException, "invalid_auth"), + (ClientError, "cannot_connect"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_rituals_account: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow with different errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + mock_rituals_account.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new_correct_password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_rituals_account.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "new_correct_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new_correct_password" + + +async def test_reauth_migrated_entry( + hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful reauth flow (updating credentials).""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_EMAIL, + data={}, + title=TEST_EMAIL, + version=2, + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_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_PASSWORD: "new_correct_password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: "new_correct_password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index d4d7376a564..ff71fcbf579 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -1,10 +1,10 @@ """Tests for the Rituals Perfume Genie integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import aiohttp -from homeassistant.components.rituals_perfume_genie.const import DOMAIN +from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,17 +16,39 @@ from .common import ( mock_diffuser_v1_battery_cartridge, ) +from tests.common import MockConfigEntry -async def test_config_entry_not_ready(hass: HomeAssistant) -> None: + +async def test_migration_v1_to_v2( + hass: HomeAssistant, + mock_rituals_account: AsyncMock, + old_mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from V1 (account_hash) to V2 (credentials).""" + old_mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(old_mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert old_mock_config_entry.version == 2 + assert ACCOUNT_HASH not in old_mock_config_entry.data + assert old_mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_rituals_account: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test the Rituals configuration entry setup if connection to Rituals is missing.""" - config_entry = mock_config_entry(unique_id="id_123_not_ready") - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.rituals_perfume_genie.Account.get_devices", - side_effect=aiohttp.ClientError, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY + mock_config_entry.add_to_hass(hass) + mock_rituals_account.get_devices.side_effect = aiohttp.ClientError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_config_entry_unload(hass: HomeAssistant) -> None: