From f72047eb021a6e42f0e6f09adeca4d107fcd25b0 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 6 Oct 2025 17:36:46 +0100 Subject: [PATCH] Add new Nintendo Parental Controls integration (#145343) Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/nintendo_parental/__init__.py | 51 +++++++++ .../nintendo_parental/config_flow.py | 61 +++++++++++ .../components/nintendo_parental/const.py | 5 + .../nintendo_parental/coordinator.py | 52 +++++++++ .../components/nintendo_parental/entity.py | 41 +++++++ .../nintendo_parental/manifest.json | 11 ++ .../nintendo_parental/quality_scale.yaml | 81 ++++++++++++++ .../components/nintendo_parental/sensor.py | 91 ++++++++++++++++ .../components/nintendo_parental/strings.json | 38 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/nintendo_parental/__init__.py | 1 + .../components/nintendo_parental/conftest.py | 93 ++++++++++++++++ tests/components/nintendo_parental/const.py | 5 + .../nintendo_parental/test_config_flow.py | 101 ++++++++++++++++++ 18 files changed, 646 insertions(+) create mode 100644 homeassistant/components/nintendo_parental/__init__.py create mode 100644 homeassistant/components/nintendo_parental/config_flow.py create mode 100644 homeassistant/components/nintendo_parental/const.py create mode 100644 homeassistant/components/nintendo_parental/coordinator.py create mode 100644 homeassistant/components/nintendo_parental/entity.py create mode 100644 homeassistant/components/nintendo_parental/manifest.json create mode 100644 homeassistant/components/nintendo_parental/quality_scale.yaml create mode 100644 homeassistant/components/nintendo_parental/sensor.py create mode 100644 homeassistant/components/nintendo_parental/strings.json create mode 100644 tests/components/nintendo_parental/__init__.py create mode 100644 tests/components/nintendo_parental/conftest.py create mode 100644 tests/components/nintendo_parental/const.py create mode 100644 tests/components/nintendo_parental/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 3235a5b73df..f518040f55b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1065,6 +1065,8 @@ build.json @home-assistant/supervisor /homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum +/homeassistant/components/nintendo_parental/ @pantherale0 +/tests/components/nintendo_parental/ @pantherale0 /homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe diff --git a/homeassistant/components/nintendo_parental/__init__.py b/homeassistant/components/nintendo_parental/__init__.py new file mode 100644 index 00000000000..91b4ebee1cb --- /dev/null +++ b/homeassistant/components/nintendo_parental/__init__.py @@ -0,0 +1,51 @@ +"""The Nintendo Switch Parental Controls integration.""" + +from __future__ import annotations + +from pynintendoparental import Authenticator +from pynintendoparental.exceptions import ( + InvalidOAuthConfigurationException, + InvalidSessionTokenException, +) + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SESSION_TOKEN, DOMAIN +from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: NintendoParentalConfigEntry +) -> bool: + """Set up Nintendo Switch Parental Controls from a config entry.""" + try: + nintendo_auth = await Authenticator.complete_login( + auth=None, + response_token=entry.data[CONF_SESSION_TOKEN], + is_session_token=True, + client_session=async_get_clientsession(hass), + ) + except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_expired", + ) from err + entry.runtime_data = coordinator = NintendoUpdateCoordinator( + hass, nintendo_auth, entry + ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: NintendoParentalConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/nintendo_parental/config_flow.py b/homeassistant/components/nintendo_parental/config_flow.py new file mode 100644 index 00000000000..1bb16e6bb11 --- /dev/null +++ b/homeassistant/components/nintendo_parental/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Nintendo Switch Parental Controls integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from pynintendoparental import Authenticator +from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SESSION_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NintendoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nintendo Switch Parental Controls.""" + + def __init__(self) -> None: + """Initialize a new config flow instance.""" + self.auth: Authenticator | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if self.auth is None: + self.auth = Authenticator.generate_login( + client_session=async_get_clientsession(self.hass) + ) + + if user_input is not None: + try: + await self.auth.complete_login( + self.auth, user_input[CONF_API_TOKEN], False + ) + except (ValueError, InvalidSessionTokenException, HttpException): + errors["base"] = "invalid_auth" + else: + if TYPE_CHECKING: + assert self.auth.account_id + await self.async_set_unique_id(self.auth.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.auth.account_id, + data={ + CONF_SESSION_TOKEN: self.auth.get_session_token, + }, + ) + return self.async_show_form( + step_id="user", + description_placeholders={"link": self.auth.login_url}, + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/nintendo_parental/const.py b/homeassistant/components/nintendo_parental/const.py new file mode 100644 index 00000000000..0cea2e56ac8 --- /dev/null +++ b/homeassistant/components/nintendo_parental/const.py @@ -0,0 +1,5 @@ +"""Constants for the Nintendo Switch Parental Controls integration.""" + +DOMAIN = "nintendo_parental" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_SESSION_TOKEN = "session_token" diff --git a/homeassistant/components/nintendo_parental/coordinator.py b/homeassistant/components/nintendo_parental/coordinator.py new file mode 100644 index 00000000000..49b4fae60f3 --- /dev/null +++ b/homeassistant/components/nintendo_parental/coordinator.py @@ -0,0 +1,52 @@ +"""Nintendo Parental Controls data coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pynintendoparental import Authenticator, NintendoParental +from pynintendoparental.exceptions import InvalidOAuthConfigurationException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +type NintendoParentalConfigEntry = ConfigEntry[NintendoUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=60) + + +class NintendoUpdateCoordinator(DataUpdateCoordinator[None]): + """Nintendo data update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + authenticator: Authenticator, + config_entry: NintendoParentalConfigEntry, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + self.api = NintendoParental( + authenticator, hass.config.time_zone, hass.config.language + ) + + async def _async_update_data(self) -> None: + """Update data from Nintendo's API.""" + try: + return await self.api.update() + except InvalidOAuthConfigurationException as err: + raise ConfigEntryError( + err, translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err diff --git a/homeassistant/components/nintendo_parental/entity.py b/homeassistant/components/nintendo_parental/entity.py new file mode 100644 index 00000000000..74d3bcae8a7 --- /dev/null +++ b/homeassistant/components/nintendo_parental/entity.py @@ -0,0 +1,41 @@ +"""Base entity definition for Nintendo Parental.""" + +from __future__ import annotations + +from pynintendoparental.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NintendoUpdateCoordinator + + +class NintendoDevice(CoordinatorEntity[NintendoUpdateCoordinator]): + """Represent a Nintendo Switch.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: NintendoUpdateCoordinator, device: Device, key: str + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = f"{device.device_id}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Nintendo", + name=device.name, + sw_version=device.extra["firmwareVersion"]["displayedVersion"], + ) + + async def async_added_to_hass(self) -> None: + """When entity is loaded.""" + await super().async_added_to_hass() + self._device.add_device_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """When will be removed from HASS.""" + self._device.remove_device_callback(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/nintendo_parental/manifest.json b/homeassistant/components/nintendo_parental/manifest.json new file mode 100644 index 00000000000..1e4dbdb342a --- /dev/null +++ b/homeassistant/components/nintendo_parental/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "nintendo_parental", + "name": "Nintendo Switch Parental Controls", + "codeowners": ["@pantherale0"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nintendo_parental", + "iot_class": "cloud_polling", + "loggers": ["pynintendoparental"], + "quality_scale": "bronze", + "requirements": ["pynintendoparental==1.0.1"] +} diff --git a/homeassistant/components/nintendo_parental/quality_scale.yaml b/homeassistant/components/nintendo_parental/quality_scale.yaml new file mode 100644 index 00000000000..523d4fe68ce --- /dev/null +++ b/homeassistant/components/nintendo_parental/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: exempt + comment: | + No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + No specific icons defined. + reconfiguration-flow: todo + repair-issues: + comment: | + No issues in integration + status: exempt + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/nintendo_parental/sensor.py b/homeassistant/components/nintendo_parental/sensor.py new file mode 100644 index 00000000000..803fb39bcb4 --- /dev/null +++ b/homeassistant/components/nintendo_parental/sensor.py @@ -0,0 +1,91 @@ +"""Sensor platform for Nintendo Parental.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator +from .entity import Device, NintendoDevice + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class NintendoParentalSensor(StrEnum): + """Store keys for Nintendo Parental sensors.""" + + PLAYING_TIME = "playing_time" + TIME_REMAINING = "time_remaining" + + +@dataclass(kw_only=True, frozen=True) +class NintendoParentalSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo Parental sensor entities.""" + + value_fn: Callable[[Device], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[NintendoParentalSensorEntityDescription, ...] = ( + NintendoParentalSensorEntityDescription( + key=NintendoParentalSensor.PLAYING_TIME, + translation_key=NintendoParentalSensor.PLAYING_TIME, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.today_playing_time, + ), + NintendoParentalSensorEntityDescription( + key=NintendoParentalSensor.TIME_REMAINING, + translation_key=NintendoParentalSensor.TIME_REMAINING, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.today_time_remaining, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NintendoParentalConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + async_add_devices( + NintendoParentalSensorEntity(entry.runtime_data, device, sensor) + for device in entry.runtime_data.api.devices.values() + for sensor in SENSOR_DESCRIPTIONS + ) + + +class NintendoParentalSensorEntity(NintendoDevice, SensorEntity): + """Represent a single sensor.""" + + entity_description: NintendoParentalSensorEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + description: NintendoParentalSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/nintendo_parental/strings.json b/homeassistant/components/nintendo_parental/strings.json new file mode 100644 index 00000000000..f35746b41f3 --- /dev/null +++ b/homeassistant/components/nintendo_parental/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "description": "To obtain your access token, click [Nintendo Login]({link}) to sign in to your Nintendo account. Then, for the account you want to link, right-click on the red **Select this person** button and choose **Copy Link Address**.", + "data": { + "api_token": "Access token" + }, + "data_description": { + "api_token": "The link copied from the Nintendo website" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "playing_time": { + "name": "Used screen time" + }, + "time_remaining": { + "name": "Screen time remaining" + } + } + }, + "exceptions": { + "auth_expired": { + "message": "Authentication expired. Please remove and re-add the integration to reconnect." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index dbd749370ca..fad6bf96939 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -440,6 +440,7 @@ FLOWS = { "nightscout", "niko_home_control", "nina", + "nintendo_parental", "nmap_tracker", "nmbs", "nobo_hub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4f49dad82dc..3cf28545b78 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4459,6 +4459,12 @@ "iot_class": "cloud_polling", "single_config_entry": true }, + "nintendo_parental": { + "name": "Nintendo Switch Parental Controls", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nissan_leaf": { "name": "Nissan Leaf", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 7f9fac77716..a35aa909e9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2209,6 +2209,9 @@ pynetio==0.1.9.1 # homeassistant.components.nina pynina==0.3.6 +# homeassistant.components.nintendo_parental +pynintendoparental==1.0.1 + # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daf40ae32de..bd497b6bd51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1845,6 +1845,9 @@ pynetgear==0.10.10 # homeassistant.components.nina pynina==0.3.6 +# homeassistant.components.nintendo_parental +pynintendoparental==1.0.1 + # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/tests/components/nintendo_parental/__init__.py b/tests/components/nintendo_parental/__init__.py new file mode 100644 index 00000000000..89853538f8e --- /dev/null +++ b/tests/components/nintendo_parental/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nintendo Switch Parental Controls integration.""" diff --git a/tests/components/nintendo_parental/conftest.py b/tests/components/nintendo_parental/conftest.py new file mode 100644 index 00000000000..c6da3c8748b --- /dev/null +++ b/tests/components/nintendo_parental/conftest.py @@ -0,0 +1,93 @@ +"""Common fixtures for the Nintendo Switch Parental Controls tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from pynintendoparental.device import Device +import pytest + +from homeassistant.components.nintendo_parental.const import DOMAIN + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={"session_token": API_TOKEN}, + unique_id=ACCOUNT_ID, + ) + + +@pytest.fixture +def mock_nintendo_device() -> Device: + """Return a mocked device.""" + mock = AsyncMock(spec=Device) + mock.device_id = "testdevid" + mock.name = "Home Assistant Test" + mock.extra = {"device": {"firmwareVersion": {"displayedVersion": "99.99.99"}}} + mock.limit_time = 120 + mock.today_playing_time = 110 + return mock + + +@pytest.fixture +def mock_nintendo_authenticator() -> Generator[MagicMock]: + """Mock Nintendo Authenticator.""" + with ( + patch( + "homeassistant.components.nintendo_parental.Authenticator", + autospec=True, + ) as mock_auth_class, + patch( + "homeassistant.components.nintendo_parental.config_flow.Authenticator", + new=mock_auth_class, + ), + ): + mock_auth = MagicMock() + mock_auth._id_token = API_TOKEN + mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59) + mock_auth.account_id = ACCOUNT_ID + mock_auth.login_url = LOGIN_URL + mock_auth.get_session_token = API_TOKEN + # Patch complete_login as an AsyncMock on both instance and class as this is a class method + mock_auth.complete_login = AsyncMock() + type(mock_auth).complete_login = mock_auth.complete_login + mock_auth_class.generate_login.return_value = mock_auth + yield mock_auth + + +@pytest.fixture +def mock_nintendo_client( + mock_nintendo_device: Device, +) -> Generator[AsyncMock]: + """Mock a Nintendo client.""" + with ( + patch( + "homeassistant.components.nintendo_parental.NintendoParental", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nintendo_parental.config_flow.NintendoParental", + new=mock_client, + ), + ): + client = mock_client.return_value + client.update.return_value = True + client.devices.return_value = {"testdevid": mock_nintendo_device} + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nintendo_parental.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nintendo_parental/const.py b/tests/components/nintendo_parental/const.py new file mode 100644 index 00000000000..5d8e3f7b713 --- /dev/null +++ b/tests/components/nintendo_parental/const.py @@ -0,0 +1,5 @@ +"""Constants for the Nintendo Parental Controls test suite.""" + +ACCOUNT_ID = "aabbccddee112233" +API_TOKEN = "valid_token" +LOGIN_URL = "http://example.com" diff --git a/tests/components/nintendo_parental/test_config_flow.py b/tests/components/nintendo_parental/test_config_flow.py new file mode 100644 index 00000000000..13216257572 --- /dev/null +++ b/tests/components/nintendo_parental/test_config_flow.py @@ -0,0 +1,101 @@ +"""Test the Nintendo Switch Parental Controls config flow.""" + +from unittest.mock import AsyncMock + +from pynintendoparental.exceptions import InvalidSessionTokenException + +from homeassistant import config_entries +from homeassistant.components.nintendo_parental.const import CONF_SESSION_TOKEN, DOMAIN +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test a full and successful config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "link" in result["description_placeholders"] + assert result["description_placeholders"]["link"] == LOGIN_URL + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_ID + assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN + assert result["result"].unique_id == ACCOUNT_ID + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test that the flow aborts if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test handling of invalid authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "link" in result["description_placeholders"] + + # Simulate invalid authentication by raising an exception + mock_nintendo_authenticator.complete_login.side_effect = ( + InvalidSessionTokenException + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "invalid_token"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Now ensure that the flow can be recovered + mock_nintendo_authenticator.complete_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_ID + assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN + assert result["result"].unique_id == ACCOUNT_ID