From ca4fd1d23297020bad6771db8da1cd59785dd5d4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 21 Jul 2026 16:22:08 +0200 Subject: [PATCH] Migrate NextDNS integration to use subentries (#175067) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/nextdns/__init__.py | 170 +++++++++++- .../components/nextdns/binary_sensor.py | 11 +- homeassistant/components/nextdns/button.py | 11 +- .../components/nextdns/config_flow.py | 175 +++++++++--- homeassistant/components/nextdns/const.py | 2 + .../components/nextdns/coordinator.py | 4 +- .../components/nextdns/diagnostics.py | 27 +- homeassistant/components/nextdns/entity.py | 5 +- homeassistant/components/nextdns/sensor.py | 14 +- homeassistant/components/nextdns/strings.json | 32 ++- homeassistant/components/nextdns/switch.py | 11 +- tests/components/nextdns/conftest.py | 42 ++- .../nextdns/snapshots/test_diagnostics.ambr | 251 ++++++++--------- tests/components/nextdns/test_config_flow.py | 159 +++++++++-- tests/components/nextdns/test_diagnostics.py | 2 +- tests/components/nextdns/test_init.py | 253 +++++++++++++++++- 16 files changed, 939 insertions(+), 230 deletions(-) diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 221be4ffd829..0e77c236bd9e 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass +from types import MappingProxyType from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -18,11 +19,17 @@ from nextdns import ( ) from tenacity import RetryError -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CONNECTION, @@ -34,6 +41,7 @@ from .const import ( ATTR_STATUS, CONF_PROFILE_ID, DOMAIN, + SUBENTRY_TYPE_PROFILE, ) from .coordinator import ( NextDnsConnectionUpdateCoordinator, @@ -50,8 +58,8 @@ type NextDnsConfigEntry = ConfigEntry[NextDnsData] @dataclass -class NextDnsData: - """Data for the NextDNS integration.""" +class NextDnsCoordinators: + """Coordinators for a NextDNS profile.""" connection: NextDnsUpdateCoordinator[ConnectionStatus] dnssec: NextDnsUpdateCoordinator[AnalyticsDnssec] @@ -62,6 +70,15 @@ class NextDnsData: status: NextDnsUpdateCoordinator[AnalyticsStatus] +@dataclass +class NextDnsData: + """Runtime data for the NextDNS integration.""" + + client: NextDns + profiles: dict[str, NextDnsCoordinators] + + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator), @@ -74,10 +91,117 @@ COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up NextDNS.""" + await async_migrate_integration(hass) + return True + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, tuple[NextDnsConfigEntry, bool]] = {} + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + for entry in entries: + profile_id = entry.data[CONF_PROFILE_ID] + profile_name = entry.title + + subentry = ConfigSubentry( + data=MappingProxyType({CONF_PROFILE_ID: profile_id}), + subentry_type=SUBENTRY_TYPE_PROFILE, + title=profile_name, + unique_id=profile_id, + ) + + if entry.data[CONF_API_KEY] not in api_keys_entries: + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) + + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, profile_id)}) + + for entity_entry in entities: + entity_disabled_by = entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + entity_entry.entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + ) + + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_identifiers={(DOMAIN, profile_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + if parent_entry.entry_id != entry.entry_id: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + data={CONF_API_KEY: entry.data[CONF_API_KEY]}, + title="NextDNS", + version=2, + unique_id=None, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool: """Set up NextDNS as config entry.""" api_key = entry.data[CONF_API_KEY] - profile_id = entry.data[CONF_PROFILE_ID] websession = async_get_clientsession(hass) try: @@ -98,25 +222,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b translation_placeholders={"entry": entry.title}, ) from err - tasks = [] - coordinators = {} + profiles: dict[str, NextDnsCoordinators] = {} - # Independent DataUpdateCoordinator is used for each API endpoint to avoid - # unnecessary requests when entities using this endpoint are disabled. - for coordinator_name, coordinator_class in COORDINATORS: - coordinator = coordinator_class(hass, entry, nextdns, profile_id) - tasks.append(coordinator.async_config_entry_first_refresh()) - coordinators[coordinator_name] = coordinator + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE): + subentry_id = subentry.subentry_id + profile_id = subentry.data[CONF_PROFILE_ID] + tasks = [] + coordinators = {} - await asyncio.gather(*tasks) + # Independent DataUpdateCoordinator is used for each API endpoint to avoid + # unnecessary requests when entities using this endpoint are disabled. + for coordinator_name, coordinator_class in COORDINATORS: + coordinator = coordinator_class( + hass, entry, nextdns, profile_id, subentry_id + ) + tasks.append(coordinator.async_config_entry_first_refresh()) + coordinators[coordinator_name] = coordinator - entry.runtime_data = NextDnsData(**coordinators) + await asyncio.gather(*tasks) + + profiles[subentry_id] = NextDnsCoordinators(**coordinators) + + entry.runtime_data = NextDnsData(client=nextdns, profiles=profiles) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def _async_update_listener( + hass: HomeAssistant, entry: NextDnsConfigEntry +) -> None: + """Reload the config entry when subentries change.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index 1c713b8b7bea..00849290eae8 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -52,11 +52,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" - coordinator = entry.runtime_data.connection - - async_add_entities( - NextDnsBinarySensor(coordinator, description) for description in SENSORS - ) + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + coordinator = profile_data.connection + async_add_entities( + (NextDnsBinarySensor(coordinator, description) for description in SENSORS), + config_subentry_id=subentry_id, + ) class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity): diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index e0e074dba931..feee17e269ed 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -31,10 +31,13 @@ async def async_setup_entry( entry: NextDnsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add aNextDNS entities from a config_entry.""" - coordinator = entry.runtime_data.status - - async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) + """Add NextDNS entities from a config_entry.""" + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + coordinator = profile_data.status + async_add_entities( + [NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)], + config_subentry_id=subentry_id, + ) class NextDnsButton(NextDnsEntity, ButtonEntity): diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 8cbde0b65323..ced39ec9cc6c 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -9,51 +9,58 @@ from nextdns import ApiError, InvalidApiKeyError, NextDns from tenacity import RetryError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_PROFILE_ID, DOMAIN +from .const import CONF_PROFILE_ID, DOMAIN, SUBENTRY_TYPE_PROFILE AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) _LOGGER = logging.getLogger(__name__) -async def async_init_nextdns( - hass: HomeAssistant, api_key: str, profile_id: str | None = None -) -> NextDns: - """Check if credentials and profile_id are valid.""" +async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: + """Check if credentials are valid.""" websession = async_get_clientsession(hass) - nextdns = await NextDns.create(websession, api_key) - - if profile_id: - if not any(profile.id == profile_id for profile in nextdns.profiles): - raise ProfileNotAvailable - - return nextdns + return await NextDns.create(websession, api_key) async def async_validate_new_api_key( - hass: HomeAssistant, user_input: dict[str, Any], profile_id: str + hass: HomeAssistant, user_input: dict[str, Any], profile_ids: list[str] ) -> dict[str, str]: """Validate the new API key during reconfiguration or reauth.""" errors: dict[str, str] = {} try: - await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id) + nextdns = await async_init_nextdns(hass, user_input[CONF_API_KEY]) except InvalidApiKeyError: errors["base"] = "invalid_api_key" except ApiError, ClientConnectorError, RetryError, TimeoutError: errors["base"] = "cannot_connect" - except ProfileNotAvailable: - errors["base"] = "profile_not_available" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + else: + for profile_id in profile_ids: + if not any(profile.id == profile_id for profile in nextdns.profiles): + errors["base"] = "profile_not_available" + break return errors @@ -61,7 +68,7 @@ async def async_validate_new_api_key( class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for NextDNS.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -77,6 +84,9 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: self.api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_API_KEY: self.api_key}) + try: self.nextdns = await async_init_nextdns(self.hass, self.api_key) except InvalidApiKeyError: @@ -102,23 +112,36 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - profile_name = user_input[CONF_PROFILE_NAME] - profile_id = self.nextdns.get_profile_id(profile_name) - - await self.async_set_unique_id(profile_id) - self._abort_if_unique_id_configured() + profile_id = user_input[CONF_PROFILE_ID] return self.async_create_entry( - title=profile_name, - data={CONF_PROFILE_ID: profile_id, CONF_API_KEY: self.api_key}, + title="NextDNS", + data={CONF_API_KEY: self.api_key}, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_PROFILE, + "data": {CONF_PROFILE_ID: profile_id}, + "title": self.nextdns.get_profile_name(profile_id), + "unique_id": profile_id, + }, + ], ) return self.async_show_form( step_id="profiles", data_schema=vol.Schema( { - vol.Required(CONF_PROFILE_NAME): vol.In( - [profile.name for profile in self.nextdns.profiles] + vol.Required(CONF_PROFILE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=profile.id, + label=profile.name, + ) + for profile in self.nextdns.profiles + ], + mode=SelectSelectorMode.LIST, + ) ) } ), @@ -139,8 +162,12 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): entry = self._get_reauth_entry() if user_input is not None: + profile_ids = [ + subentry.data[CONF_PROFILE_ID] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + ] errors = await async_validate_new_api_key( - self.hass, user_input, entry.data[CONF_PROFILE_ID] + self.hass, user_input, profile_ids ) if errors.get("base") == "profile_not_available": return self.async_abort(reason="profile_not_available") @@ -165,8 +192,12 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): entry = self._get_reconfigure_entry() if user_input is not None: + profile_ids = [ + subentry.data[CONF_PROFILE_ID] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + ] errors = await async_validate_new_api_key( - self.hass, user_input, entry.data[CONF_PROFILE_ID] + self.hass, user_input, profile_ids ) if errors.get("base") == "profile_not_available": return self.async_abort(reason="profile_not_available") @@ -183,6 +214,82 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {SUBENTRY_TYPE_PROFILE: ProfileSubentryFlowHandler} -class ProfileNotAvailable(HomeAssistantError): - """Error to indicate that the profile is not available after reconfig/reauth.""" + +class ProfileSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for profile.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.nextdns: NextDns + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> SubentryFlowResult: + """Handle the profile step.""" + entry = self._get_entry() + if entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors: dict[str, str] = {} + + self.nextdns = entry.runtime_data.client + + if user_input is not None: + profile_id = user_input[CONF_PROFILE_ID] + + if any( + subentry.unique_id == profile_id + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + ): + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=self.nextdns.get_profile_name(profile_id), + data={CONF_PROFILE_ID: profile_id}, + unique_id=profile_id, + ) + + # Filter out already configured profiles + configured_profiles = { + subentry.data[CONF_PROFILE_ID] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE) + } + available_profiles = [ + profile + for profile in self.nextdns.profiles + if profile.id not in configured_profiles + ] + + if not available_profiles: + return self.async_abort(reason="all_profiles_configured") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_PROFILE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=profile.id, + label=profile.name, + ) + for profile in available_profiles + ], + mode=SelectSelectorMode.LIST, + ) + ) + } + ), + errors=errors, + ) diff --git a/homeassistant/components/nextdns/const.py b/homeassistant/components/nextdns/const.py index b8210c1939ca..53cae439b7f3 100644 --- a/homeassistant/components/nextdns/const.py +++ b/homeassistant/components/nextdns/const.py @@ -12,6 +12,8 @@ ATTR_STATUS = "status" CONF_PROFILE_ID = "profile_id" +SUBENTRY_TYPE_PROFILE = "profile" + UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) UPDATE_INTERVAL_SETTINGS = timedelta(minutes=1) diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 3966e041f80a..a2fceb9c1498 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -51,16 +51,18 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, + subentry_id: str, ) -> None: """Initialize.""" self.nextdns = nextdns self.profile_id = profile_id + self.subentry_id = subentry_id super().__init__( hass, _LOGGER, config_entry=config_entry, - name=DOMAIN, + name=f"{DOMAIN}_{subentry_id}", update_interval=self._update_interval, ) diff --git a/homeassistant/components/nextdns/diagnostics.py b/homeassistant/components/nextdns/diagnostics.py index 60f9d74e492a..87efc4d06916 100644 --- a/homeassistant/components/nextdns/diagnostics.py +++ b/homeassistant/components/nextdns/diagnostics.py @@ -17,19 +17,22 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: NextDnsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - dnssec_coordinator = config_entry.runtime_data.dnssec - encryption_coordinator = config_entry.runtime_data.encryption - ip_versions_coordinator = config_entry.runtime_data.ip_versions - protocols_coordinator = config_entry.runtime_data.protocols - settings_coordinator = config_entry.runtime_data.settings - status_coordinator = config_entry.runtime_data.status + profiles_data: list[dict[str, Any]] = [] + for subentry_id, profile_data in config_entry.runtime_data.profiles.items(): + subentry = config_entry.subentries[subentry_id] + profiles_data.append( + { + "subentry_title": subentry.title, + "dnssec_coordinator_data": asdict(profile_data.dnssec.data), + "encryption_coordinator_data": asdict(profile_data.encryption.data), + "ip_versions_coordinator_data": asdict(profile_data.ip_versions.data), + "protocols_coordinator_data": asdict(profile_data.protocols.data), + "settings_coordinator_data": asdict(profile_data.settings.data), + "status_coordinator_data": asdict(profile_data.status.data), + } + ) return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "dnssec_coordinator_data": asdict(dnssec_coordinator.data), - "encryption_coordinator_data": asdict(encryption_coordinator.data), - "ip_versions_coordinator_data": asdict(ip_versions_coordinator.data), - "protocols_coordinator_data": asdict(protocols_coordinator.data), - "settings_coordinator_data": asdict(settings_coordinator.data), - "status_coordinator_data": asdict(status_coordinator.data), + "profiles": profiles_data, } diff --git a/homeassistant/components/nextdns/entity.py b/homeassistant/components/nextdns/entity.py index 7e86d1d246ca..6fb493990ab7 100644 --- a/homeassistant/components/nextdns/entity.py +++ b/homeassistant/components/nextdns/entity.py @@ -24,12 +24,13 @@ class NextDnsEntity[CoordinatorDataT: NextDnsData]( ) -> None: """Initialize.""" super().__init__(coordinator) + subentry = coordinator.config_entry.subentries[coordinator.subentry_id] self._attr_device_info = DeviceInfo( configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(coordinator.profile_id))}, + identifiers={(DOMAIN, coordinator.profile_id)}, manufacturer="NextDNS Inc.", - name=coordinator.nextdns.get_profile_name(coordinator.profile_id), + name=subentry.title, ) self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 0cf52ef973a1..65d973d64279 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -287,12 +287,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a NextDNS entities from a config_entry.""" - async_add_entities( - NextDnsSensor( - getattr(entry.runtime_data, description.coordinator_type), description + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + async_add_entities( + ( + NextDnsSensor( + getattr(profile_data, description.coordinator_type), description + ) + for description in SENSORS + ), + config_subentry_id=subentry_id, ) - for description in SENSORS - ) class NextDnsSensor[CoordinatorDataT: NextDnsData]( diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 18341c938a2b..18aff8b73f6b 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "This NextDNS profile is already configured.", + "all_profiles_configured": "All NextDNS profiles are already configured.", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" @@ -14,10 +15,10 @@ "step": { "profiles": { "data": { - "profile_name": "Profile" + "profile_id": "Profile" }, "data_description": { - "profile_name": "The NextDNS configuration profile you want to integrate" + "profile_id": "The NextDNS configuration profile you want to integrate" } }, "reauth_confirm": { @@ -46,6 +47,31 @@ } } }, + "config_subentries": { + "profile": { + "abort": { + "all_profiles_configured": "[%key:component::nextdns::config::abort::all_profiles_configured%]", + "already_configured": "This NextDNS profile is already configured.", + "entry_not_loaded": "The NextDNS configuration entry is not loaded. Please ensure it is set up correctly before adding a profile." + }, + "entry_type": "NextDNS profile", + "initiate_flow": { + "user": "Add profile" + }, + "step": { + "user": { + "data": { + "profile_id": "[%key:component::nextdns::config::step::profiles::data::profile_id%]" + }, + "data_description": { + "profile_id": "[%key:component::nextdns::config::step::profiles::data_description::profile_id%]" + }, + "description": "Select the NextDNS profile you want to add.", + "title": "Add NextDNS profile" + } + } + } + }, "entity": { "binary_sensor": { "device_connection_status": { diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 5d8405afda84..c207baa956be 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -533,11 +533,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" - coordinator = entry.runtime_data.settings - - async_add_entities( - NextDnsSwitch(coordinator, description) for description in SWITCHES - ) + for subentry_id, profile_data in entry.runtime_data.profiles.items(): + coordinator = profile_data.settings + async_add_entities( + (NextDnsSwitch(coordinator, description) for description in SWITCHES), + config_subentry_id=subentry_id, + ) class NextDnsSwitch(NextDnsEntity, SwitchEntity): diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py index 9699ffe2f307..5081620e7029 100644 --- a/tests/components/nextdns/conftest.py +++ b/tests/components/nextdns/conftest.py @@ -15,7 +15,12 @@ from nextdns import ( ) import pytest -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.components.nextdns.const import ( + CONF_PROFILE_ID, + DOMAIN, + SUBENTRY_TYPE_PROFILE, +) +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_API_KEY from tests.common import ( @@ -47,14 +52,41 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_subentries() -> list[ConfigSubentryData]: + """Return a list of mock subentries.""" + return [ + ConfigSubentryData( + data={CONF_PROFILE_ID: "xyz12"}, + subentry_type=SUBENTRY_TYPE_PROFILE, + title="Fake Profile", + unique_id="xyz12", + ) + ] + + +@pytest.fixture +def mock_config_entry(mock_subentries: list[ConfigSubentryData]) -> MockConfigEntry: """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="NextDNS", + data={CONF_API_KEY: "fake_api_key"}, + entry_id="d9aa37407ddac7b964a99e86312288d6", + version=2, + subentries_data=mock_subentries, + ) + + +@pytest.fixture +def mock_config_entry_v1() -> MockConfigEntry: + """Return a v1 mocked config entry for migration testing.""" return MockConfigEntry( domain=DOMAIN, title="Fake Profile", unique_id="xyz12", data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, entry_id="d9aa37407ddac7b964a99e86312288d6", + version=1, ) @@ -80,7 +112,11 @@ def mock_nextdns_client(mock_nextdns: AsyncMock) -> AsyncMock: client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS client.get_analytics_status.return_value = ANALYTICS_STATUS client.get_profile_id = Mock(return_value="xyz12") - client.get_profile_name = Mock(return_value="Fake Profile") + client.get_profile_name = Mock( + side_effect=lambda profile_id: next( + profile.name for profile in client.profiles if profile.id == profile_id + ) + ) client.get_profiles.return_value = PROFILES client.get_settings.return_value = SETTINGS client.set_setting.return_value = True diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index f55c381af4ec..e585ad7696d3 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -4,13 +4,11 @@ 'config_entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', - 'profile_id': '**REDACTED**', }), 'disabled_by': None, 'discovery_keys': dict({ }), 'domain': 'nextdns', - 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', 'minor_version': 1, 'options': dict({ }), @@ -18,125 +16,138 @@ 'pref_disable_polling': False, 'source': 'user', 'subentries': list([ + dict({ + 'data': dict({ + 'profile_id': '**REDACTED**', + }), + 'subentry_type': 'profile', + 'title': 'Fake Profile', + 'unique_id': '**REDACTED**', + }), ]), - 'title': 'Fake Profile', - 'unique_id': '**REDACTED**', - 'version': 1, - }), - 'dnssec_coordinator_data': dict({ - 'not_validated_queries': 25, - 'validated_queries': 75, - 'validated_queries_ratio': 75.0, - }), - 'encryption_coordinator_data': dict({ - 'encrypted_queries': 60, - 'encrypted_queries_ratio': 60.0, - 'unencrypted_queries': 40, - }), - 'ip_versions_coordinator_data': dict({ - 'ipv4_queries': 90, - 'ipv6_queries': 10, - 'ipv6_queries_ratio': 10.0, - }), - 'protocols_coordinator_data': dict({ - 'doh3_queries': 15, - 'doh3_queries_ratio': 13.0, - 'doh_queries': 20, - 'doh_queries_ratio': 17.4, - 'doq_queries': 10, - 'doq_queries_ratio': 8.7, - 'dot_queries': 30, - 'dot_queries_ratio': 26.1, - 'tcp_queries': 0, - 'tcp_queries_ratio': 0.0, - 'udp_queries': 40, - 'udp_queries_ratio': 34.8, - }), - 'settings_coordinator_data': dict({ - 'ai_threat_detection': True, - 'allow_affiliate': True, - 'anonymized_ecs': True, - 'bav': True, - 'block_9gag': True, - 'block_amazon': True, - 'block_bereal': True, - 'block_blizzard': True, - 'block_bypass_methods': True, - 'block_chatgpt': True, - 'block_csam': True, - 'block_dailymotion': True, - 'block_dating': True, - 'block_ddns': True, - 'block_discord': True, - 'block_disguised_trackers': True, - 'block_disneyplus': True, - 'block_ebay': True, - 'block_facebook': True, - 'block_fortnite': True, - 'block_gambling': True, - 'block_google_chat': True, - 'block_hbomax': True, - 'block_hulu': True, - 'block_imgur': True, - 'block_instagram': True, - 'block_leagueoflegends': True, - 'block_mastodon': True, - 'block_messenger': True, - 'block_minecraft': True, - 'block_netflix': True, - 'block_nrd': True, - 'block_online_gaming': True, - 'block_page': False, - 'block_parked_domains': True, - 'block_pinterest': True, - 'block_piracy': True, - 'block_playstation_network': True, - 'block_porn': True, - 'block_primevideo': True, - 'block_reddit': True, - 'block_roblox': True, - 'block_signal': True, - 'block_skype': True, - 'block_snapchat': True, - 'block_social_networks': True, - 'block_spotify': True, - 'block_steam': True, - 'block_telegram': True, - 'block_tiktok': True, - 'block_tinder': True, - 'block_tumblr': True, - 'block_twitch': True, - 'block_twitter': True, - 'block_video_streaming': True, - 'block_vimeo': True, - 'block_vk': True, - 'block_whatsapp': True, - 'block_xboxlive': True, - 'block_youtube': True, - 'block_zoom': True, - 'cache_boost': True, - 'cname_flattening': True, - 'cryptojacking_protection': True, - 'dga_protection': True, - 'dns_rebinding_protection': True, - 'google_safe_browsing': False, - 'idn_homograph_attacks_protection': True, - 'logs': True, - 'logs_location': 'ch', - 'logs_retention': 720, - 'safesearch': False, - 'threat_intelligence_feeds': True, - 'typosquatting_protection': True, - 'web3': True, - 'youtube_restricted_mode': False, - }), - 'status_coordinator_data': dict({ - 'all_queries': 100, - 'allowed_queries': 30, - 'blocked_queries': 20, - 'blocked_queries_ratio': 20.0, - 'default_queries': 40, - 'relayed_queries': 10, + 'title': 'NextDNS', + 'unique_id': None, + 'version': 2, }), + 'profiles': list([ + dict({ + 'dnssec_coordinator_data': dict({ + 'not_validated_queries': 25, + 'validated_queries': 75, + 'validated_queries_ratio': 75.0, + }), + 'encryption_coordinator_data': dict({ + 'encrypted_queries': 60, + 'encrypted_queries_ratio': 60.0, + 'unencrypted_queries': 40, + }), + 'ip_versions_coordinator_data': dict({ + 'ipv4_queries': 90, + 'ipv6_queries': 10, + 'ipv6_queries_ratio': 10.0, + }), + 'protocols_coordinator_data': dict({ + 'doh3_queries': 15, + 'doh3_queries_ratio': 13.0, + 'doh_queries': 20, + 'doh_queries_ratio': 17.4, + 'doq_queries': 10, + 'doq_queries_ratio': 8.7, + 'dot_queries': 30, + 'dot_queries_ratio': 26.1, + 'tcp_queries': 0, + 'tcp_queries_ratio': 0.0, + 'udp_queries': 40, + 'udp_queries_ratio': 34.8, + }), + 'settings_coordinator_data': dict({ + 'ai_threat_detection': True, + 'allow_affiliate': True, + 'anonymized_ecs': True, + 'bav': True, + 'block_9gag': True, + 'block_amazon': True, + 'block_bereal': True, + 'block_blizzard': True, + 'block_bypass_methods': True, + 'block_chatgpt': True, + 'block_csam': True, + 'block_dailymotion': True, + 'block_dating': True, + 'block_ddns': True, + 'block_discord': True, + 'block_disguised_trackers': True, + 'block_disneyplus': True, + 'block_ebay': True, + 'block_facebook': True, + 'block_fortnite': True, + 'block_gambling': True, + 'block_google_chat': True, + 'block_hbomax': True, + 'block_hulu': True, + 'block_imgur': True, + 'block_instagram': True, + 'block_leagueoflegends': True, + 'block_mastodon': True, + 'block_messenger': True, + 'block_minecraft': True, + 'block_netflix': True, + 'block_nrd': True, + 'block_online_gaming': True, + 'block_page': False, + 'block_parked_domains': True, + 'block_pinterest': True, + 'block_piracy': True, + 'block_playstation_network': True, + 'block_porn': True, + 'block_primevideo': True, + 'block_reddit': True, + 'block_roblox': True, + 'block_signal': True, + 'block_skype': True, + 'block_snapchat': True, + 'block_social_networks': True, + 'block_spotify': True, + 'block_steam': True, + 'block_telegram': True, + 'block_tiktok': True, + 'block_tinder': True, + 'block_tumblr': True, + 'block_twitch': True, + 'block_twitter': True, + 'block_video_streaming': True, + 'block_vimeo': True, + 'block_vk': True, + 'block_whatsapp': True, + 'block_xboxlive': True, + 'block_youtube': True, + 'block_zoom': True, + 'cache_boost': True, + 'cname_flattening': True, + 'cryptojacking_protection': True, + 'dga_protection': True, + 'dns_rebinding_protection': True, + 'google_safe_browsing': False, + 'idn_homograph_attacks_protection': True, + 'logs': True, + 'logs_location': 'ch', + 'logs_retention': 720, + 'safesearch': False, + 'threat_intelligence_feeds': True, + 'typosquatting_protection': True, + 'web3': True, + 'youtube_restricted_mode': False, + }), + 'status_coordinator_data': dict({ + 'all_queries': 100, + 'allowed_queries': 30, + 'blocked_queries': 20, + 'blocked_queries_ratio': 20.0, + 'default_queries': 40, + 'relayed_queries': 10, + }), + 'subentry_title': 'Fake Profile', + }), + ]), }) # --- diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 280dcc8336bd..ace312728a71 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -1,14 +1,19 @@ """Define tests for the NextDNS config flow.""" +from types import MappingProxyType from unittest.mock import AsyncMock from nextdns import ApiError, InvalidApiKeyError, ProfileInfo import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME +from homeassistant.components.nextdns.const import ( + CONF_PROFILE_ID, + DOMAIN, + SUBENTRY_TYPE_PROFILE, +) +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -40,14 +45,17 @@ async def test_form_create_entry( assert result["step_id"] == "profiles" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + result["flow_id"], {CONF_PROFILE_ID: "xyz12"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Fake Profile" + assert result["title"] == "NextDNS" assert result["data"][CONF_API_KEY] == "fake_api_key" - assert result["data"][CONF_PROFILE_ID] == "xyz12" - assert result["result"].unique_id == "xyz12" + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == SUBENTRY_TYPE_PROFILE + assert subentry["title"] == "Fake Profile" + assert subentry["data"][CONF_PROFILE_ID] == "xyz12" assert len(mock_setup_entry.mock_calls) == 1 @@ -97,14 +105,13 @@ async def test_form_errors( assert result["step_id"] == "profiles" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + result["flow_id"], {CONF_PROFILE_ID: "xyz12"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Fake Profile" + assert result["title"] == "NextDNS" assert result["data"][CONF_API_KEY] == "fake_api_key" - assert result["data"][CONF_PROFILE_ID] == "xyz12" - assert result["result"].unique_id == "xyz12" + assert len(result["subentries"]) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -114,22 +121,20 @@ async def test_form_already_configured( mock_nextdns_client: AsyncMock, mock_nextdns: AsyncMock, ) -> None: - """Test that errors are shown when duplicates are added.""" + """Test that the flow aborts when API key is already configured.""" await init_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "fake_api_key"}, ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} - ) - + # When a config entry with the same API key exists, the flow aborts + # Users should add profiles via the subentry flow assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -324,3 +329,123 @@ async def test_reconfigure_flow_no_profile( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "profile_not_available" + + +async def test_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test creating a profile subentry.""" + # Add a second profile to the client + mock_nextdns_client.profiles = [ + ProfileInfo(id="xyz12", fingerprint="xyz12", name="Fake Profile"), + ProfileInfo(id="abc34", fingerprint="abc34", name="Second Profile"), + ] + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PROFILE_ID: "abc34"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Second Profile" + assert result["data"][CONF_PROFILE_ID] == "abc34" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert len(entry.subentries) == 2 + + +async def test_subentry_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test subentry flow when the profile gets configured between form display and submit.""" + # Add a second and third profile so the flow doesn't abort immediately + second_profile = ProfileInfo( + id="abc34", fingerprint="xyz789", name="Second Profile" + ) + third_profile = ProfileInfo(id="def56", fingerprint="uvw456", name="Third Profile") + mock_nextdns_client.profiles = [ + *mock_nextdns_client.profiles, + second_profile, + third_profile, + ] + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate a race condition where the second profile gets configured + # between showing the form and submitting it + hass.config_entries.async_add_subentry( + mock_config_entry, + ConfigSubentry( + data=MappingProxyType({CONF_PROFILE_ID: "abc34"}), + subentry_type=SUBENTRY_TYPE_PROFILE, + title="Second Profile", + unique_id="abc34", + ), + ) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PROFILE_ID: "abc34"}, + ) + + # Abort flow when a profile is already configured between form display and submit + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_subentry_flow_all_profiles_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test subentry flow when all profiles are already configured.""" + await init_integration(hass, mock_config_entry) + + # Only one profile available and it's already configured + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "all_profiles_configured" + + +async def test_subentry_flow_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry flow when the entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 5d870c03471c..e705b693e20d 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -26,4 +26,4 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry - ) == snapshot(exclude=props("created_at", "modified_at")) + ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id")) diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 1eaaeba3a52b..55a0a525a481 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -6,10 +6,19 @@ from nextdns import ApiError, InvalidApiKeyError import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.nextdns.const import ( + CONF_PROFILE_ID, + DOMAIN, + SUBENTRY_TYPE_PROFILE, +) +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration @@ -63,7 +72,6 @@ async def test_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) async def test_config_auth_failed( @@ -88,3 +96,240 @@ async def test_config_auth_failed( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +async def test_migrate_entry_v1_to_v2( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + mock_nextdns_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create old device and entity with old-style identifiers before migration + mock_config_entry_v1.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "xyz12")}, + manufacturer="NextDNS Inc.", + name="Fake Profile", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "xyz12_dns_queries", + config_entry=mock_config_entry_v1, + ) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + # Verify migration was successful + assert mock_config_entry_v1.version == 2 + assert mock_config_entry_v1.title == "NextDNS" + assert mock_config_entry_v1.state is ConfigEntryState.LOADED + + # Verify data was migrated correctly + assert CONF_PROFILE_ID not in mock_config_entry_v1.data + assert mock_config_entry_v1.data[CONF_API_KEY] == "fake_api_key" + + # Verify subentry was created + assert len(mock_config_entry_v1.subentries) == 1 + subentry = list(mock_config_entry_v1.subentries.values())[0] + assert subentry.subentry_type == SUBENTRY_TYPE_PROFILE + assert subentry.title == "Fake Profile" + assert subentry.data[CONF_PROFILE_ID] == "xyz12" + assert subentry.unique_id == "xyz12" + + # Verify device was migrated and linked to subentry + device = device_registry.async_get_device(identifiers={(DOMAIN, "xyz12")}) + assert device is not None + assert device.config_entries_subentries == { + mock_config_entry_v1.entry_id: {subentry.subentry_id} + } + + # Verify entity was migrated and linked to subentry + entity_entry = entity_registry.async_get("sensor.nextdns_xyz12_dns_queries") + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry_v1.entry_id + assert entity_entry.config_subentry_id == subentry.subentry_id + + +async def test_migrate_entry_v1_to_v2_merge_same_api_key( + hass: HomeAssistant, + mock_nextdns_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration merges v1 entries with the same API key.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + title="Profile One", + unique_id="abc11", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "abc11"}, + entry_id="entry1_id", + version=1, + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + title="Profile Two", + unique_id="def22", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "def22"}, + entry_id="entry2_id", + version=1, + ) + entry1.add_to_hass(hass) + entry2.add_to_hass(hass) + + # Create old devices with old-style identifiers + device_registry.async_get_or_create( + config_entry_id=entry1.entry_id, + identifiers={(DOMAIN, "abc11")}, + manufacturer="NextDNS Inc.", + name="Profile One", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_registry.async_get_or_create( + config_entry_id=entry2.entry_id, + identifiers={(DOMAIN, "def22")}, + manufacturer="NextDNS Inc.", + name="Profile Two", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + # Create old entities for both entries to verify they are migrated + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "profile_one_dns_queries", + config_entry=entry1, + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "profile_two_dns_queries", + config_entry=entry2, + ) + + await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + + # Verify entry1 was migrated and is loaded + assert entry1.version == 2 + assert entry1.title == "NextDNS" + assert entry1.state is ConfigEntryState.LOADED + assert CONF_PROFILE_ID not in entry1.data + assert entry1.data[CONF_API_KEY] == "fake_api_key" + + # Verify entry2 was removed + assert hass.config_entries.async_get_entry(entry2.entry_id) is None + + # Verify entry1 has two subentries (both profiles merged) + assert len(entry1.subentries) == 2 + subentries = list(entry1.subentries.values()) + profile_ids = {s.data[CONF_PROFILE_ID] for s in subentries} + assert profile_ids == {"abc11", "def22"} + titles = {s.title for s in subentries} + assert titles == {"Profile One", "Profile Two"} + + # Verify devices were migrated to entry1 with existing identifiers + device_abc = device_registry.async_get_device(identifiers={(DOMAIN, "abc11")}) + assert device_abc is not None + assert entry1.entry_id in device_abc.config_entries + + device_def = device_registry.async_get_device(identifiers={(DOMAIN, "def22")}) + assert device_def is not None + assert entry1.entry_id in device_def.config_entries + + # Verify entities from both entries were migrated to entry1 + entity_entry_1 = entity_registry.async_get("sensor.nextdns_profile_one_dns_queries") + assert entity_entry_1 is not None + assert entity_entry_1.config_entry_id == entry1.entry_id + assert entity_entry_1.config_subentry_id is not None + + entity_entry_2 = entity_registry.async_get("sensor.nextdns_profile_two_dns_queries") + assert entity_entry_2 is not None + assert entity_entry_2.config_entry_id == entry1.entry_id + assert entity_entry_2.config_subentry_id is not None + + +async def test_migrate_entry_v1_to_v2_disabled_entry( + hass: HomeAssistant, + mock_nextdns_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration updates disabled_by when merging disabled and enabled entries.""" + entry1 = MockConfigEntry( + domain=DOMAIN, + title="Profile One", + unique_id="abc11", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "abc11"}, + entry_id="entry1_id", + version=1, + ) + entry2 = MockConfigEntry( + domain=DOMAIN, + title="Profile Two", + unique_id="def22", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "def22"}, + entry_id="entry2_id", + version=1, + disabled_by=ConfigEntryDisabler.USER, + ) + entry1.add_to_hass(hass) + entry2.add_to_hass(hass) + + # Create device and entity for disabled entry2 with CONFIG_ENTRY disabled_by + device_registry.async_get_or_create( + config_entry_id=entry1.entry_id, + identifiers={(DOMAIN, "abc11")}, + manufacturer="NextDNS Inc.", + name="Profile One", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + device2 = device_registry.async_get_or_create( + config_entry_id=entry2.entry_id, + identifiers={(DOMAIN, "def22")}, + manufacturer="NextDNS Inc.", + name="Profile Two", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + + entity_registry.async_get_or_create( + domain="sensor", + platform=DOMAIN, + unique_id="def22_all_queries", + suggested_object_id="profile_two_dns_queries", + config_entry=entry2, + device_id=device2.id, + disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY, + ) + + await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + + # Verify entry1 was migrated and entry2 was removed + assert entry1.version == 2 + assert entry1.state is ConfigEntryState.LOADED + assert hass.config_entries.async_get_entry(entry2.entry_id) is None + + # Find the subentry for the disabled profile + subentry2 = next( + s for s in entry1.subentries.values() if s.data[CONF_PROFILE_ID] == "def22" + ) + + # Verify device disabled_by was changed from CONFIG_ENTRY to USER + device = device_registry.async_get_device(identifiers={(DOMAIN, "def22")}) + assert device is not None + assert device.disabled_by is dr.DeviceEntryDisabler.USER + + # Verify entity disabled_by was changed from CONFIG_ENTRY to DEVICE + entity_entry = entity_registry.async_get("sensor.profile_two_dns_queries") + assert entity_entry is not None + assert entity_entry.config_entry_id == entry1.entry_id + assert entity_entry.config_subentry_id == subentry2.subentry_id + assert entity_entry.disabled_by is er.RegistryEntryDisabler.DEVICE