diff --git a/CODEOWNERS b/CODEOWNERS index d4557e226b217..d1d133e6694b2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -515,6 +515,8 @@ CLAUDE.md @home-assistant/core /tests/components/eurotronic_cometblue/ @rikroe /homeassistant/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core +/homeassistant/components/eveonline/ @ronaldvdmeer +/tests/components/eveonline/ @ronaldvdmeer /homeassistant/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb /homeassistant/components/ezviz/ @RenierM26 diff --git a/homeassistant/components/eveonline/__init__.py b/homeassistant/components/eveonline/__init__.py new file mode 100644 index 0000000000000..3eb8f9f732c42 --- /dev/null +++ b/homeassistant/components/eveonline/__init__.py @@ -0,0 +1,55 @@ +"""The Eve Online integration.""" + +from __future__ import annotations + +from eveonline import EveOnlineClient + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import CONF_CHARACTER_ID, CONF_CHARACTER_NAME +from .coordinator import EveOnlineConfigEntry, EveOnlineCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: EveOnlineConfigEntry) -> bool: + """Set up Eve Online from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + "OAuth2 implementation unavailable, check application credentials" + ) from err + auth = AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), + OAuth2Session(hass, entry, implementation), + ) + client = EveOnlineClient(auth=auth) + + coordinator = EveOnlineCoordinator( + hass, + entry, + client, + entry.data[CONF_CHARACTER_ID], + entry.data[CONF_CHARACTER_NAME], + ) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EveOnlineConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eveonline/api.py b/homeassistant/components/eveonline/api.py new file mode 100644 index 0000000000000..6e706c45d0bc2 --- /dev/null +++ b/homeassistant/components/eveonline/api.py @@ -0,0 +1,41 @@ +"""API helpers for the Eve Online integration.""" + +from __future__ import annotations + +from typing import cast + +from aiohttp import ClientError, ClientSession +from eveonline.auth import AbstractAuth + +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import UpdateFailed + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Eve Online authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: OAuth2Session, + ) -> None: + """Initialize Eve Online auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + try: + await self._oauth_session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + "Authentication failed, please reauthenticate" + ) from err + except (OAuth2TokenRequestTransientError, ClientError) as err: + raise UpdateFailed(f"Failed to refresh OAuth token: {err}") from err + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/eveonline/application_credentials.py b/homeassistant/components/eveonline/application_credentials.py new file mode 100644 index 0000000000000..dc77ebb99c016 --- /dev/null +++ b/homeassistant/components/eveonline/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials for the Eve Online integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return the Eve Online authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/eveonline/config_flow.py b/homeassistant/components/eveonline/config_flow.py new file mode 100644 index 0000000000000..bc8dd6ad2bffa --- /dev/null +++ b/homeassistant/components/eveonline/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for the Eve Online integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import jwt + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .const import CONF_CHARACTER_ID, CONF_CHARACTER_NAME, DOMAIN, SCOPES + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Handle OAuth2 config flow for Eve Online. + + Each config entry represents one authenticated character. + Multiple characters can be added as separate entries. + """ + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data to include in the authorize URL.""" + return {"scope": " ".join(SCOPES)} + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + reauth_entry = self._get_reauth_entry() + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={ + "character": reauth_entry.data[CONF_CHARACTER_NAME] + }, + ) + + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow.""" + try: + token = data["token"]["access_token"] + character_info = _decode_eve_jwt(token) + except ValueError, KeyError, jwt.DecodeError: + return self.async_abort(reason="oauth_error") + + data[CONF_CHARACTER_ID] = character_info[CONF_CHARACTER_ID] + data[CONF_CHARACTER_NAME] = character_info[CONF_CHARACTER_NAME] + + await self.async_set_unique_id(str(character_info[CONF_CHARACTER_ID])) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + reauth_entry, + title=character_info[CONF_CHARACTER_NAME], + data=data, + ) + + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=character_info[CONF_CHARACTER_NAME], + data=data, + ) + + +def _decode_eve_jwt(token: str) -> dict[str, Any]: + """Decode an Eve SSO JWT to extract character info.""" + decoded = jwt.decode(token, options={"verify_signature": False}) + sub = decoded.get("sub", "") + sub_parts = sub.split(":") + if len(sub_parts) != 3 or sub_parts[0] != "CHARACTER" or sub_parts[1] != "EVE": + raise ValueError(sub) + return { + CONF_CHARACTER_ID: int(sub_parts[2]), + CONF_CHARACTER_NAME: decoded["name"], + } diff --git a/homeassistant/components/eveonline/const.py b/homeassistant/components/eveonline/const.py new file mode 100644 index 0000000000000..bee5e0f5e6de3 --- /dev/null +++ b/homeassistant/components/eveonline/const.py @@ -0,0 +1,17 @@ +"""Constants for the Eve Online integration.""" + +from typing import Final + +DOMAIN: Final = "eveonline" + +CONF_CHARACTER_ID: Final = "character_id" +CONF_CHARACTER_NAME: Final = "character_name" + +OAUTH2_AUTHORIZE: Final = "https://login.eveonline.com/v2/oauth/authorize" +OAUTH2_TOKEN: Final = "https://login.eveonline.com/v2/oauth/token" + +SCOPES: Final[list[str]] = [ + "esi-location.read_location.v1", + "esi-location.read_ship_type.v1", + "esi-wallet.read_character_wallet.v1", +] diff --git a/homeassistant/components/eveonline/coordinator.py b/homeassistant/components/eveonline/coordinator.py new file mode 100644 index 0000000000000..38b26df55ae01 --- /dev/null +++ b/homeassistant/components/eveonline/coordinator.py @@ -0,0 +1,108 @@ +"""Coordinator for the Eve Online integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import aiohttp +from eveonline import EveOnlineClient, EveOnlineError +from eveonline.models import CharacterLocation, CharacterShip + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type EveOnlineConfigEntry = ConfigEntry[EveOnlineCoordinator] + + +@dataclass(slots=True, kw_only=True) +class EveOnlineData: + """Eve Online character data.""" + + character_id: int + character_name: str + wallet_balance: float | None = None + location: CharacterLocation | None = None + solar_system_name: str | None = None + ship: CharacterShip | None = None + ship_type_name: str | None = None + + +class EveOnlineCoordinator(DataUpdateCoordinator[EveOnlineData]): + """Coordinator for Eve Online character data.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: EveOnlineClient, + character_id: int, + character_name: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN} {character_name} ({character_id})", + update_interval=timedelta(minutes=1), + ) + self.client = client + self.character_id = character_id + self.character_name = character_name + + async def _async_update_data(self) -> EveOnlineData: + """Fetch character data from ESI.""" + try: + wallet = await self.client.async_get_wallet_balance(self.character_id) + except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err: + raise UpdateFailed( + f"Error communicating with Eve Online API: {err}" + ) from err + + location: CharacterLocation | None = None + ship: CharacterShip | None = None + try: + location = await self.client.async_get_character_location(self.character_id) + except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err: + _LOGGER.debug("Failed to fetch location: %s", err) + try: + ship = await self.client.async_get_character_ship(self.character_id) + except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err: + _LOGGER.debug("Failed to fetch ship: %s", err) + + solar_system_name: str | None = None + ship_type_name: str | None = None + + ids_to_resolve = [] + if location: + ids_to_resolve.append(location.solar_system_id) + if ship: + ids_to_resolve.append(ship.ship_type_id) + + if ids_to_resolve: + try: + resolved = await self.client.async_resolve_names(ids_to_resolve) + resolved_by_id = {r.id: r.name for r in resolved} + if location: + solar_system_name = resolved_by_id.get(location.solar_system_id) + if ship: + ship_type_name = resolved_by_id.get(ship.ship_type_id) + except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err: + _LOGGER.debug("Failed to resolve names: %s", err) + + return EveOnlineData( + character_id=self.character_id, + character_name=self.character_name, + wallet_balance=wallet.balance, + location=location, + solar_system_name=solar_system_name, + ship=ship, + ship_type_name=ship_type_name, + ) diff --git a/homeassistant/components/eveonline/entity.py b/homeassistant/components/eveonline/entity.py new file mode 100644 index 0000000000000..e785c7b3a6a73 --- /dev/null +++ b/homeassistant/components/eveonline/entity.py @@ -0,0 +1,28 @@ +"""Base entity for the Eve Online integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EveOnlineCoordinator + + +class EveOnlineCharacterEntity(CoordinatorEntity[EveOnlineCoordinator]): + """Base entity for an Eve Online character.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: EveOnlineCoordinator, key: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.character_id}_{key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(coordinator.character_id))}, + manufacturer="CCP Games", + model="Character", + name=coordinator.character_name, + configuration_url=f"https://evewho.com/character/{coordinator.character_id}", + ) diff --git a/homeassistant/components/eveonline/manifest.json b/homeassistant/components/eveonline/manifest.json new file mode 100644 index 0000000000000..f8d29a6e1fbef --- /dev/null +++ b/homeassistant/components/eveonline/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "eveonline", + "name": "Eve Online", + "codeowners": ["@ronaldvdmeer"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/eveonline", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["eveonline"], + "quality_scale": "bronze", + "requirements": ["python-eveonline==0.4.0"] +} diff --git a/homeassistant/components/eveonline/quality_scale.yaml b/homeassistant/components/eveonline/quality_scale.yaml new file mode 100644 index 0000000000000..93c3c2e9e84e8 --- /dev/null +++ b/homeassistant/components/eveonline/quality_scale.yaml @@ -0,0 +1,95 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide service actions. + appropriate-polling: done + brands: + status: done + comment: "Brands PR submitted: https://github.com/home-assistant/brands/pull/10133" + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide service actions. + docs-high-level-description: + status: done + comment: "Docs PR submitted: https://github.com/home-assistant/home-assistant.io/pull/44762" + docs-installation-instructions: + status: done + comment: "Docs PR submitted: https://github.com/home-assistant/home-assistant.io/pull/44762" + docs-removal-instructions: + status: done + comment: "Docs PR submitted: https://github.com/home-assistant/home-assistant.io/pull/44762" + entity-event-setup: + status: exempt + comment: Integration uses DataUpdateCoordinator; entities do not subscribe to events directly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: >- + The integration uses OAuth2; the token exchange with the EVE SSO server + is the only pre-configure step possible. No additional connectivity test + can be performed before the OAuth round-trip completes. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not provide service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery: + status: exempt + comment: Eve Online is a cloud service — no local discovery possible. + discovery-update-info: + status: exempt + comment: Eve Online is a cloud service — no local discovery possible. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: Eve Online is a cloud service with no physical devices. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Each config entry represents a single character; the device topology is fixed. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: OAuth2-only integration with no user-configurable settings. + repair-issues: todo + stale-devices: + status: exempt + comment: Each config entry represents a single character; devices never go stale. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/eveonline/sensor.py b/homeassistant/components/eveonline/sensor.py new file mode 100644 index 0000000000000..105b5122bdc3c --- /dev/null +++ b/homeassistant/components/eveonline/sensor.py @@ -0,0 +1,78 @@ +"""Sensor platform for the Eve Online integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EveOnlineConfigEntry, EveOnlineCoordinator, EveOnlineData +from .entity import EveOnlineCharacterEntity + + +@dataclass(frozen=True, kw_only=True) +class EveOnlineSensorDescription(SensorEntityDescription): + """Describe an Eve Online sensor.""" + + value_fn: Callable[[EveOnlineData], str | float | None] + + +SENSORS: tuple[EveOnlineSensorDescription, ...] = ( + EveOnlineSensorDescription( + key="wallet_balance", + translation_key="wallet_balance", + native_unit_of_measurement="ISK", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda data: data.wallet_balance, + ), + EveOnlineSensorDescription( + key="location", + translation_key="location", + value_fn=lambda data: data.solar_system_name, + ), + EveOnlineSensorDescription( + key="ship", + translation_key="ship", + value_fn=lambda data: data.ship_type_name, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EveOnlineConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Eve Online sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + EveOnlineCharacterSensor(coordinator, description) for description in SENSORS + ) + + +class EveOnlineCharacterSensor(EveOnlineCharacterEntity, SensorEntity): + """Representation of an Eve Online sensor.""" + + entity_description: EveOnlineSensorDescription + + def __init__( + self, + coordinator: EveOnlineCoordinator, + description: EveOnlineSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> str | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/eveonline/strings.json b/homeassistant/components/eveonline/strings.json new file mode 100644 index 0000000000000..fc7755bef6099 --- /dev/null +++ b/homeassistant/components/eveonline/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_account_mismatch": "The authenticated character does not match the character that needed re-authentication.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Eve Online integration needs to re-authenticate your account for character: {character}", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "entity": { + "sensor": { + "location": { + "name": "Location" + }, + "ship": { + "name": "Ship" + }, + "wallet_balance": { + "name": "Wallet balance" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e91629..8492f53fe0219 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ "dropbox", "ekeybionyx", "electric_kiwi", + "eveonline", "fitbit", "gentex_homelink", "geocaching", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d981856b0e492..20be6bc45bd01 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -211,6 +211,7 @@ "essent", "eufylife_ble", "eurotronic_cometblue", + "eveonline", "evil_genius_labs", "ezviz", "faa_delays", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9c76b93ae4669..9a76664d13cf6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1949,6 +1949,12 @@ "matter" ] }, + "eveonline": { + "name": "Eve Online", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "evergy": { "name": "Evergy", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 52c0b9094c8c8..6f474bed6306c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2589,6 +2589,9 @@ python-ecobee-api==0.3.2 # homeassistant.components.etherscan python-etherscan-api==0.0.3 +# homeassistant.components.eveonline +python-eveonline==0.4.0 + # homeassistant.components.familyhub python-family-hub-local==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6f6c5467ea74..ebea60d70e760 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2212,6 +2212,9 @@ python-duco-client==0.3.9 # homeassistant.components.ecobee python-ecobee-api==0.3.2 +# homeassistant.components.eveonline +python-eveonline==0.4.0 + # homeassistant.components.fully_kiosk python-fullykiosk==0.0.15 diff --git a/tests/components/eveonline/__init__.py b/tests/components/eveonline/__init__.py new file mode 100644 index 0000000000000..9ca9f680f86be --- /dev/null +++ b/tests/components/eveonline/__init__.py @@ -0,0 +1 @@ +"""Tests for the Eve Online integration.""" diff --git a/tests/components/eveonline/conftest.py b/tests/components/eveonline/conftest.py new file mode 100644 index 0000000000000..62486ec9cab1f --- /dev/null +++ b/tests/components/eveonline/conftest.py @@ -0,0 +1,110 @@ +"""Fixtures for the Eve Online integration tests.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch + +from eveonline.models import ( + CharacterLocation, + CharacterShip, + UniverseName, + WalletBalance, +) +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.eveonline.const import ( + CONF_CHARACTER_ID, + CONF_CHARACTER_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "test-client-id" +CLIENT_SECRET = "test-client-secret" +CHARACTER_ID = 12345678 +CHARACTER_NAME = "Test Capsuleer" +SOLAR_SYSTEM_ID = 30000142 +SOLAR_SYSTEM_NAME = "Jita" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to set up application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CHARACTER_NAME, + unique_id=str(CHARACTER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 1200, + "expires_at": time.time() + 1200, + "token_type": "Bearer", + }, + CONF_CHARACTER_ID: CHARACTER_ID, + CONF_CHARACTER_NAME: CHARACTER_NAME, + }, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_eveonline_client() -> Generator[AsyncMock]: + """Mock the EveOnlineClient.""" + with patch( + "homeassistant.components.eveonline.EveOnlineClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.async_get_wallet_balance.return_value = WalletBalance(balance=1234567.89) + client.async_get_character_location.return_value = CharacterLocation( + solar_system_id=SOLAR_SYSTEM_ID + ) + client.async_resolve_names.return_value = [ + UniverseName( + id=SOLAR_SYSTEM_ID, name=SOLAR_SYSTEM_NAME, category="solar_system" + ), + UniverseName(id=587, name="Rifter", category="inventory_type"), + ] + client.async_get_character_ship.return_value = CharacterShip( + ship_type_id=587, ship_item_id=1000000, ship_name="i.probe" + ) + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> MockConfigEntry: + """Set up the Eve Online integration.""" + 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 + return mock_config_entry diff --git a/tests/components/eveonline/snapshots/test_sensor.ambr b/tests/components/eveonline/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..10a28ea77bd50 --- /dev/null +++ b/tests/components/eveonline/snapshots/test_sensor.ambr @@ -0,0 +1,158 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.test_capsuleer_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Location', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': '12345678_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_capsuleer_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Location', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Jita', + }) +# --- +# name: test_sensor_snapshot[sensor.test_capsuleer_ship-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_ship', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ship', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ship', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ship', + 'unique_id': '12345678_ship', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_capsuleer_ship-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Ship', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_ship', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Rifter', + }) +# --- +# name: test_sensor_snapshot[sensor.test_capsuleer_wallet_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_wallet_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wallet balance', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wallet balance', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wallet_balance', + 'unique_id': '12345678_wallet_balance', + 'unit_of_measurement': 'ISK', + }) +# --- +# name: test_sensor_snapshot[sensor.test_capsuleer_wallet_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Wallet balance', + 'state_class': , + 'unit_of_measurement': 'ISK', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_wallet_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234567.89', + }) +# --- diff --git a/tests/components/eveonline/test_config_flow.py b/tests/components/eveonline/test_config_flow.py new file mode 100644 index 0000000000000..8eb09f73aea6d --- /dev/null +++ b/tests/components/eveonline/test_config_flow.py @@ -0,0 +1,436 @@ +"""Test the Eve Online config flow.""" + +import base64 +import json +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.eveonline.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SCOPES, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +CHARACTER_ID = 95465499 +CHARACTER_NAME = "CCP Bartender" + + +def _make_jwt(character_id: int, character_name: str) -> str: + """Create a fake Eve SSO JWT token.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=") + payload_data = { + "sub": f"CHARACTER:EVE:{character_id}", + "name": character_name, + } + payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b"=") + signature = base64.urlsafe_b64encode(b"fakesig").rstrip(b"=") + return f"{header.decode()}.{payload.decode()}.{signature.decode()}" + + +def _make_jwt_with_sub(sub: str) -> str: + """Create a fake Eve SSO JWT with a custom subject for error testing.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=") + payload = base64.urlsafe_b64encode( + json.dumps({"sub": sub, "name": "Test"}).encode() + ).rstrip(b"=") + sig = base64.urlsafe_b64encode(b"fakesig").rstrip(b"=") + return f"{header.decode()}.{payload.decode()}.{sig.decode()}" + + +async def _setup_credentials(hass: HomeAssistant) -> None: + """Set up application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full OAuth2 flow.""" + await _setup_credentials(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + parsed = urlparse(result["url"]) + params = parse_qs(parsed.query) + assert parsed.scheme + "://" + parsed.netloc + parsed.path == OAUTH2_AUTHORIZE + assert params["response_type"] == ["code"] + assert params["client_id"] == [CLIENT_ID] + assert params["redirect_uri"] == ["https://example.com/auth/external/callback"] + assert params["state"] == [state] + assert set(params["scope"][0].split()) == set(SCOPES) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + fake_jwt = _make_jwt(CHARACTER_ID, CHARACTER_NAME) + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.unique_id == str(CHARACTER_ID) + assert entry.title == CHARACTER_NAME + assert entry.data["character_id"] == CHARACTER_ID + assert entry.data["character_name"] == CHARACTER_NAME + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_rejects_duplicate_character( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check that adding the same character twice is rejected.""" + await _setup_credentials(hass) + + # First flow — should succeed. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + fake_jwt = _make_jwt(CHARACTER_ID, CHARACTER_NAME) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ): + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Second flow — same character, should abort. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token-2", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + "access_token", + [ + "not-a-jwt", + "header.!!!invalid-base64!!!.signature", + _make_jwt_with_sub("WRONG:FORMAT"), + _make_jwt_with_sub("CHARACTER:SERENITY:12345"), + _make_jwt_with_sub("CHARACTER:EVE:not-a-number"), + ], +) +async def test_flow_aborts_on_bad_jwt( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, +) -> None: + """Test that bad JWT tokens abort the config flow.""" + await _setup_credentials(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth_error" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_succeeds_after_oauth_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that a new flow can succeed after a previous one failed with a bad token.""" + await _setup_credentials(hass) + + # First flow — bad JWT causes oauth_error abort. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "not-a-jwt", + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth_error" + + # Second flow — valid JWT, should succeed. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + aioclient_mock.clear_requests() + fake_jwt = _make_jwt(CHARACTER_ID, CHARACTER_NAME) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token-2", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_successful( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry, + setup_credentials: None, +) -> None: + """Test a successful reauth flow updates the existing entry.""" + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ) as mock_setup: + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert len(mock_setup.mock_calls) == 1 + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = parse_qs(urlparse(result["url"]).query)["state"][0] + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + fake_jwt = _make_jwt( + mock_config_entry.data["character_id"], + mock_config_entry.data["character_name"], + ) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.data["token"]["refresh_token"] == "new-refresh-token" + assert len(mock_setup.mock_calls) == 2 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry, + setup_credentials: None, +) -> None: + """Test reauth aborts when a different character is authenticated.""" + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ) as mock_setup: + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert len(mock_setup.mock_calls) == 1 + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = parse_qs(urlparse(result["url"]).query)["state"][0] + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": _make_jwt( + mock_config_entry.data["character_id"] + 1, + "Other Character", + ), + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.data["token"]["refresh_token"] == "mock-refresh-token" + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/eveonline/test_init.py b/tests/components/eveonline/test_init.py new file mode 100644 index 0000000000000..8ce35099b83d1 --- /dev/null +++ b/tests/components/eveonline/test_init.py @@ -0,0 +1,91 @@ +"""Test the Eve Online integration setup.""" + +from unittest.mock import AsyncMock + +import aiohttp +from eveonline import EveOnlineError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test successful setup of a config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "exception", + [ + EveOnlineError("API unavailable"), + aiohttp.ClientError("Connection reset"), + ], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, + exception: Exception, +) -> None: + """Test setup failure when the primary endpoint is unavailable.""" + mock_eveonline_client.async_get_wallet_balance.side_effect = exception + + 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_unload_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test successful unloading of a config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test setup failure when authentication fails.""" + mock_eveonline_client.async_get_wallet_balance.side_effect = ConfigEntryAuthFailed( + "Token expired" + ) + + 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_ERROR + + +async def test_optional_endpoint_error_does_not_fail_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that errors on optional endpoints don't prevent the entry from loading.""" + mock_eveonline_client.async_get_character_location.side_effect = EveOnlineError( + "Endpoint down" + ) + + 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 diff --git a/tests/components/eveonline/test_sensor.py b/tests/components/eveonline/test_sensor.py new file mode 100644 index 0000000000000..be2b1de5ce628 --- /dev/null +++ b/tests/components/eveonline/test_sensor.py @@ -0,0 +1,108 @@ +"""Test the Eve Online sensor platform.""" + +from unittest.mock import AsyncMock + +import aiohttp +from eveonline import EveOnlineError +from eveonline.models import WalletBalance +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor states match snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_unavailable( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_eveonline_client: AsyncMock, +) -> None: + """Test that sensors become unavailable when the coordinator fails.""" + mock_eveonline_client.async_get_wallet_balance.side_effect = EveOnlineError( + "API unavailable" + ) + + await init_integration.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_capsuleer_wallet_balance") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_wallet_updated( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_eveonline_client: AsyncMock, +) -> None: + """Test that wallet balance updates on coordinator refresh.""" + mock_eveonline_client.async_get_wallet_balance.return_value = WalletBalance( + balance=9999999.99 + ) + + await init_integration.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_capsuleer_wallet_balance") + assert state is not None + assert float(state.state) == 9999999.99 + + +async def test_sensor_location_shows_unknown_when_endpoint_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that the location sensor shows unknown when its endpoint fails.""" + mock_eveonline_client.async_get_character_location.side_effect = ( + aiohttp.ClientError("Connection lost") + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_capsuleer_location") + assert state is not None + assert state.state == STATE_UNKNOWN + + ship_state = hass.states.get("sensor.test_capsuleer_ship") + assert ship_state is not None + assert ship_state.state == "Rifter" + + +async def test_sensor_ship_shows_unknown_when_endpoint_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that the ship sensor shows unknown when its endpoint fails.""" + mock_eveonline_client.async_get_character_ship.side_effect = aiohttp.ClientError( + "Connection lost" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_capsuleer_ship") + assert state is not None + assert state.state == STATE_UNKNOWN + + location_state = hass.states.get("sensor.test_capsuleer_location") + assert location_state is not None + assert location_state.state == "Jita"