From f31a548d6ed5d45a22d8f35eeaf880fbab3c67c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 12:34:24 +0000 Subject: [PATCH] Fix duplicate integration entries for same email across different regions The unique_id for config entries was based solely on the email address, which prevented users from creating entries for the same account in different API regions (europe/world). Changed the unique_id format to include the region prefix (e.g. "europe_user@example.com") and added a v1->v2 migration that updates existing entries automatically. https://claude.ai/code/session_013syyaNCPaPoK6CKZfrqfez --- custom_components/owlet/__init__.py | 27 ++++++++++ custom_components/owlet/config_flow.py | 6 ++- tests/__init__.py | 3 +- tests/const.py | 6 +++ tests/test_config_flow.py | 75 +++++++++++++++++++++++++- tests/test_init.py | 46 +++++++++++++++- 6 files changed, 158 insertions(+), 5 deletions(-) diff --git a/custom_components/owlet/__init__.py b/custom_components/owlet/__init__.py index a6f93f1..a5bbeec 100644 --- a/custom_components/owlet/__init__.py +++ b/custom_components/owlet/__init__.py @@ -35,6 +35,33 @@ _LOGGER = logging.getLogger(__name__) +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry to new format.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version == 1: + # Version 1 used email-only unique_id, version 2 includes region prefix + region = config_entry.data.get(CONF_REGION, "world") + old_unique_id = config_entry.unique_id + new_unique_id = f"{region}_{old_unique_id}" + + hass.config_entries.async_update_entry( + config_entry, unique_id=new_unique_id, version=2 + ) + + _LOGGER.debug( + "Migration to version 2 successful: unique_id %s -> %s", + old_unique_id, + new_unique_id, + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Owlet Smart Sock from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/owlet/config_flow.py b/custom_components/owlet/config_flow.py index 6010b39..922376f 100644 --- a/custom_components/owlet/config_flow.py +++ b/custom_components/owlet/config_flow.py @@ -42,7 +42,7 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Owlet Smart Sock.""" - VERSION = 1 + VERSION = 2 reauth_entry: ConfigEntry | None = None def __init__(self) -> None: @@ -61,7 +61,9 @@ async def async_step_user( session=async_get_clientsession(self.hass), ) - await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + await self.async_set_unique_id( + f"{user_input[CONF_REGION]}_{user_input[CONF_USERNAME].lower()}" + ) self._abort_if_unique_id_configured() try: diff --git a/tests/__init__.py b/tests/__init__.py index 5bacde9..9f3f313 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -30,8 +30,9 @@ async def async_init_integration( """Set up integration entry.""" entry = MockConfigEntry( domain=DOMAIN, + version=2, title="sample@gmail.com", - unique_id="sample@gmail.com", + unique_id="europe_sample@gmail.com", data={ CONF_REGION: "europe", CONF_USERNAME: "sample@gmail.com", diff --git a/tests/const.py b/tests/const.py index ac53adc..b3de717 100644 --- a/tests/const.py +++ b/tests/const.py @@ -12,3 +12,9 @@ "username": "sample@gmail.com", "password": "sample", } + +CONF_INPUT_WORLD = { + "region": "world", + "username": "sample@gmail.com", + "password": "sample", +} diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index a3cebae..4e3d47e 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -18,7 +18,7 @@ from homeassistant.data_entry_flow import FlowResultType from . import async_init_integration -from .const import AUTH_RETURN, CONF_INPUT +from .const import AUTH_RETURN, CONF_INPUT, CONF_INPUT_WORLD async def test_form(hass: HomeAssistant) -> None: @@ -217,6 +217,79 @@ async def test_reauth_unknown_error(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" +async def test_flow_same_email_different_region(hass: HomeAssistant) -> None: + """Test that the same email can be added for different regions.""" + # Create first entry for europe region + with patch( + "homeassistant.components.owlet.config_flow.OwletAPI.authenticate", + return_value=AUTH_RETURN, + ), patch( + "homeassistant.components.owlet.config_flow.OwletAPI.validate_authentication" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + # Create second entry for world region with same email + with patch( + "homeassistant.components.owlet.config_flow.OwletAPI.authenticate", + return_value=AUTH_RETURN, + ), patch( + "homeassistant.components.owlet.config_flow.OwletAPI.validate_authentication" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT_WORLD, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"]["region"] == "world" + + +async def test_flow_duplicate_region_and_email(hass: HomeAssistant) -> None: + """Test that duplicate email + region is still blocked.""" + # Create first entry for europe region + with patch( + "homeassistant.components.owlet.config_flow.OwletAPI.authenticate", + return_value=AUTH_RETURN, + ), patch( + "homeassistant.components.owlet.config_flow.OwletAPI.validate_authentication" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + # Try to create duplicate entry for same region + email + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_options_flow(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" entry = await async_init_integration(hass, skip_setup=True) diff --git a/tests/test_init.py b/tests/test_init.py index 8718023..d639106 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,7 @@ """Test Owlet init.""" from __future__ import annotations +import json from unittest.mock import patch from pyowletapi.exceptions import ( @@ -14,13 +15,21 @@ CONF_OWLET_EXPIRY, CONF_OWLET_REFRESH, DOMAIN, + POLLING_INTERVAL, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_API_TOKEN, CONF_REGION, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_API_TOKEN, + CONF_REGION, + CONF_SCAN_INTERVAL, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import async_init_integration +from tests.common import MockConfigEntry, load_fixture PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -134,3 +143,38 @@ async def test_async_setup_entry_error(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.SETUP_ERROR await entry.async_unload(hass) + + +async def test_migrate_entry_v1_to_v2(hass: HomeAssistant) -> None: + """Test migration from v1 (email-only unique_id) to v2 (region_email).""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + title="sample@gmail.com", + unique_id="sample@gmail.com", + data={ + CONF_REGION: "europe", + CONF_USERNAME: "sample@gmail.com", + CONF_API_TOKEN: "api_token", + CONF_OWLET_EXPIRY: 100, + CONF_OWLET_REFRESH: "refresh_token", + }, + options={CONF_SCAN_INTERVAL: POLLING_INTERVAL}, + ) + + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.owlet.OwletAPI.get_properties", + return_value={}, + ), patch( + "homeassistant.components.owlet.OwletAPI.authenticate", return_value=None + ), patch( + "homeassistant.components.owlet.OwletAPI.get_devices", + return_value=json.loads(load_fixture("get_devices.json", "owlet")), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert entry.unique_id == "europe_sample@gmail.com"