Fix Rituals Perfume Genie (#151537)

Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Quentin Ulmer
2025-12-05 16:16:51 +01:00
committed by GitHub
parent 7fd440c4a0
commit 630b40fbba
14 changed files with 476 additions and 162 deletions

4
CODEOWNERS generated
View File

@@ -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

View File

@@ -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

View File

@@ -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 = {}
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session)
account = Account(
email=user_input[CONF_EMAIL],
password=user_input[CONF_PASSWORD],
session=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"
except ClientError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(account.email)
await self.async_set_unique_id(user_input[CONF_EMAIL])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=account.email,
data={ACCOUNT_HASH: account.account_hash},
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,
)

View File

@@ -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

View File

@@ -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."""
"""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

View File

@@ -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"]
}

View File

@@ -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%]",

2
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -0,0 +1,4 @@
"""Constants for rituals_perfume_genie tests."""
TEST_EMAIL = "test@rituals.com"
TEST_PASSWORD = "test-password"

View File

@@ -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 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: VALID_PASSWORD,
CONF_PASSWORD: TEST_PASSWORD,
},
)
await hass.async_block_till_done()
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
with patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
side_effect=AuthenticationException,
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: WRONG_PASSWORD,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
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}
)
with patch(
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: VALID_PASSWORD,
CONF_PASSWORD: TEST_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 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: VALID_PASSWORD,
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

View File

@@ -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: