Change Electric Kiwi authentication (#135231)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Michael Arthur 2025-02-06 08:01:45 +13:00 committed by GitHub
parent 15bc29f8ca
commit 94614e0376
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 753 additions and 296 deletions

View File

@ -4,12 +4,16 @@ from __future__ import annotations
import aiohttp import aiohttp
from electrickiwi_api import ElectricKiwiApi from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException from electrickiwi_api.exceptions import ApiException, AuthException
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
entity_registry as er,
)
from . import api from . import api
from .coordinator import ( from .coordinator import (
@ -44,7 +48,9 @@ async def async_setup_entry(
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
ek_api = ElectricKiwiApi( ek_api = ElectricKiwiApi(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) api.ConfigEntryElectricKiwiAuth(
aiohttp_client.async_get_clientsession(hass), session
)
) )
hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api)
account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api)
@ -53,6 +59,8 @@ async def async_setup_entry(
await ek_api.set_active_session() await ek_api.set_active_session()
await hop_coordinator.async_config_entry_first_refresh() await hop_coordinator.async_config_entry_first_refresh()
await account_coordinator.async_config_entry_first_refresh() await account_coordinator.async_config_entry_first_refresh()
except AuthException as err:
raise ConfigEntryAuthFailed from err
except ApiException as err: except ApiException as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
@ -70,3 +78,53 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version == 1 and config_entry.minor_version == 1:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
ek_api = ElectricKiwiApi(
api.ConfigEntryElectricKiwiAuth(
aiohttp_client.async_get_clientsession(hass), session
)
)
try:
await ek_api.set_active_session()
connection_details = await ek_api.get_connection_details()
except AuthException:
config_entry.async_start_reauth(hass)
return False
except ApiException:
return False
unique_id = str(ek_api.customer_number)
identifier = ek_api.electricity.identifier
hass.config_entries.async_update_entry(
config_entry, unique_id=unique_id, minor_version=2
)
entity_registry = er.async_get(hass)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
for entity in entity_entries:
assert entity.config_entry_id
entity_registry.async_update_entity(
entity.entity_id,
new_unique_id=entity.unique_id.replace(
f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}"
),
)
return True

View File

@ -2,17 +2,16 @@
from __future__ import annotations from __future__ import annotations
from typing import cast
from aiohttp import ClientSession from aiohttp import ClientSession
from electrickiwi_api import AbstractAuth from electrickiwi_api import AbstractAuth
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from .const import API_BASE_URL from .const import API_BASE_URL
class AsyncConfigEntryAuth(AbstractAuth): class ConfigEntryElectricKiwiAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" """Provide Electric Kiwi authentication tied to an OAuth2 based config entry."""
def __init__( def __init__(
@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token.""" """Return a valid access token."""
await self._oauth_session.async_ensure_token_valid() await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"]) return str(self._oauth_session.token["access_token"])
class ConfigFlowElectricKiwiAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config flow."""
def __init__(
self,
hass: HomeAssistant,
token: str,
) -> None:
"""Initialize ConfigFlowFitbitApi."""
super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the token for the Electric Kiwi API."""
return self._token

View File

@ -6,9 +6,14 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from homeassistant.config_entries import ConfigFlowResult from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN, SCOPE_VALUES from .const import DOMAIN, SCOPE_VALUES
@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler(
): ):
"""Config flow to handle Electric Kiwi OAuth2 authentication.""" """Config flow to handle Electric Kiwi OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN DOMAIN = DOMAIN
@property @property
@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler(
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required.""" """Dialog that informs the user that reauth is required."""
if user_input is None: if user_input is None:
return self.async_show_form(step_id="reauth_confirm") return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_NAME: self._get_reauth_entry().title},
)
return await self.async_step_user() return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an entry for Electric Kiwi.""" """Create an entry for Electric Kiwi."""
existing_entry = await self.async_set_unique_id(DOMAIN) ek_api = ElectricKiwiApi(
if existing_entry: api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"])
return self.async_update_reload_and_abort(existing_entry, data=data) )
return await super().async_oauth_create_entry(data)
try:
session = await ek_api.get_active_session()
except ApiException:
return self.async_abort(reason="connection_error")
unique_id = str(session.data.customer_number)
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=unique_id, data=data)

View File

@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize"
OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token"
API_BASE_URL = "https://api.electrickiwi.co.nz" API_BASE_URL = "https://api.electrickiwi.co.nz"
SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login"

View File

@ -10,7 +10,7 @@ import logging
from electrickiwi_api import ElectricKiwiApi from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.model import AccountBalance, Hop, HopIntervals from electrickiwi_api.model import AccountSummary, Hop, HopIntervals
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData:
type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData]
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]):
"""ElectricKiwi Account Data object.""" """ElectricKiwi Account Data object."""
def __init__( def __init__(
@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
name="Electric Kiwi Account Data", name="Electric Kiwi Account Data",
update_interval=ACCOUNT_SCAN_INTERVAL, update_interval=ACCOUNT_SCAN_INTERVAL,
) )
self._ek_api = ek_api self.ek_api = ek_api
async def _async_update_data(self) -> AccountBalance: async def _async_update_data(self) -> AccountSummary:
"""Fetch data from Account balance API endpoint.""" """Fetch data from Account balance API endpoint."""
try: try:
async with asyncio.timeout(60): async with asyncio.timeout(60):
return await self._ek_api.get_account_balance() return await self.ek_api.get_account_summary()
except AuthException as auth_err: except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err: except ApiException as api_err:
@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
# Polling interval. Will only be polled if there are subscribers. # Polling interval. Will only be polled if there are subscribers.
update_interval=HOP_SCAN_INTERVAL, update_interval=HOP_SCAN_INTERVAL,
) )
self._ek_api = ek_api self.ek_api = ek_api
self.hop_intervals: HopIntervals | None = None self.hop_intervals: HopIntervals | None = None
def get_hop_options(self) -> dict[str, int]: def get_hop_options(self) -> dict[str, int]:
@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
async def async_update_hop(self, hop_interval: int) -> Hop: async def async_update_hop(self, hop_interval: int) -> Hop:
"""Update selected hop and data.""" """Update selected hop and data."""
try: try:
self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) self.async_set_updated_data(await self.ek_api.post_hop(hop_interval))
except AuthException as auth_err: except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err: except ApiException as api_err:
@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
try: try:
async with asyncio.timeout(60): async with asyncio.timeout(60):
if self.hop_intervals is None: if self.hop_intervals is None:
hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals()
hop_intervals.intervals = OrderedDict( hop_intervals.intervals = OrderedDict(
filter( filter(
lambda pair: pair[1].active == 1, lambda pair: pair[1].active == 1,
@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
) )
self.hop_intervals = hop_intervals self.hop_intervals = hop_intervals
return await self._ek_api.get_hop() return await self.ek_api.get_hop()
except AuthException as auth_err: except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err: except ApiException as api_err:

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["electrickiwi-api==0.8.5"] "requirements": ["electrickiwi-api==0.9.12"]
} }

View File

@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity(
"""Initialise the HOP selection entity.""" """Initialise the HOP selection entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001 f"{coordinator.ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
) )
self.entity_description = description self.entity_description = description
self.values_dict = coordinator.get_hop_options() self.values_dict = coordinator.get_hop_options()

View File

@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from electrickiwi_api.model import AccountBalance, Hop from electrickiwi_api.model import AccountSummary, Hop
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -39,7 +39,15 @@ ATTR_HOP_PERCENTAGE = "hop_percentage"
class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription): class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription):
"""Describes Electric Kiwi sensor entity.""" """Describes Electric Kiwi sensor entity."""
value_func: Callable[[AccountBalance], float | datetime] value_func: Callable[[AccountSummary], float | datetime]
def _get_hop_percentage(account_balance: AccountSummary) -> float:
"""Return the hop percentage from account summary."""
if power := account_balance.services.get("power"):
if connection := power.connections[0]:
return float(connection.hop_percentage)
return 0.0
ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
@ -72,9 +80,7 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
translation_key="hop_power_savings", translation_key="hop_power_savings",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_func=lambda account_balance: float( value_func=_get_hop_percentage,
account_balance.connections[0].hop_percentage
),
), ),
) )
@ -165,8 +171,8 @@ class ElectricKiwiAccountEntity(
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001 f"{coordinator.ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
) )
self.entity_description = description self.entity_description = description
@ -194,8 +200,8 @@ class ElectricKiwiHOPEntity(
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001 f"{coordinator.ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
) )
self.entity_description = description self.entity_description = description

View File

@ -21,7 +21,8 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"connection_error": "[%key:common::config_flow::error::cannot_connect%]"
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

2
requirements_all.txt generated
View File

@ -824,7 +824,7 @@ ecoaliface==0.4.0
eheimdigital==1.0.5 eheimdigital==1.0.5
# homeassistant.components.electric_kiwi # homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5 electrickiwi-api==0.9.12
# homeassistant.components.elevenlabs # homeassistant.components.elevenlabs
elevenlabs==1.9.0 elevenlabs==1.9.0

View File

@ -702,7 +702,7 @@ easyenergy==2.1.2
eheimdigital==1.0.5 eheimdigital==1.0.5
# homeassistant.components.electric_kiwi # homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5 electrickiwi-api==0.9.12
# homeassistant.components.elevenlabs # homeassistant.components.elevenlabs
elevenlabs==1.9.0 elevenlabs==1.9.0

View File

@ -1 +1,13 @@
"""Tests for the Electric Kiwi integration.""" """Tests for the Electric Kiwi integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Fixture for setting up the integration with args."""
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

View File

@ -2,11 +2,18 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable, Generator from collections.abc import Generator
from time import time from time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from electrickiwi_api.model import AccountBalance, Hop, HopIntervals from electrickiwi_api.model import (
AccountSummary,
CustomerConnection,
Hop,
HopIntervals,
Service,
Session,
)
import pytest import pytest
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
@ -23,37 +30,55 @@ CLIENT_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
REDIRECT_URI = "https://example.com/auth/external/callback" REDIRECT_URI = "https://example.com/auth/external/callback"
type YieldFixture = Generator[AsyncMock]
type ComponentSetup = Callable[[], Awaitable[bool]] @pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup application credentials component."""
await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def request_setup(current_request_with_host: None) -> None: def electrickiwi_api() -> Generator[AsyncMock]:
"""Request setup.""" """Mock ek api and return values."""
with (
patch(
@pytest.fixture "homeassistant.components.electric_kiwi.ElectricKiwiApi",
def component_setup( autospec=True,
hass: HomeAssistant, config_entry: MockConfigEntry ) as mock_client,
) -> ComponentSetup: patch(
"""Fixture for setting up the integration.""" "homeassistant.components.electric_kiwi.config_flow.ElectricKiwiApi",
new=mock_client,
async def _setup_func() -> bool: ),
assert await async_setup_component(hass, "application_credentials", {}) ):
await hass.async_block_till_done() client = mock_client.return_value
await async_import_client_credential( client.customer_number = 123456
hass, client.electricity = Service(
DOMAIN, identifier="00000000DDA",
ClientCredential(CLIENT_ID, CLIENT_SECRET), service="electricity",
DOMAIN, service_status="Y",
is_primary_service=True,
) )
await hass.async_block_till_done() client.get_active_session.return_value = Session.from_dict(
config_entry.add_to_hass(hass) load_json_value_fixture("session.json", DOMAIN)
result = await hass.config_entries.async_setup(config_entry.entry_id) )
await hass.async_block_till_done() client.get_hop_intervals.return_value = HopIntervals.from_dict(
return result load_json_value_fixture("hop_intervals.json", DOMAIN)
)
return _setup_func client.get_hop.return_value = Hop.from_dict(
load_json_value_fixture("get_hop.json", DOMAIN)
)
client.get_account_summary.return_value = AccountSummary.from_dict(
load_json_value_fixture("account_summary.json", DOMAIN)
)
client.get_connection_details.return_value = CustomerConnection.from_dict(
load_json_value_fixture("connection_details.json", DOMAIN)
)
yield client
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
@ -63,7 +88,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
title="Electric Kiwi", title="Electric Kiwi",
domain=DOMAIN, domain=DOMAIN,
data={ data={
"id": "12345", "id": "123456",
"auth_implementation": DOMAIN, "auth_implementation": DOMAIN,
"token": { "token": {
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
@ -74,6 +99,54 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
}, },
}, },
unique_id=DOMAIN, unique_id=DOMAIN,
version=1,
minor_version=1,
)
@pytest.fixture(name="config_entry2")
def mock_config_entry2(hass: HomeAssistant) -> MockConfigEntry:
"""Create mocked config entry."""
return MockConfigEntry(
title="Electric Kiwi",
domain=DOMAIN,
data={
"id": "123457",
"auth_implementation": DOMAIN,
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 60,
},
},
unique_id="1234567",
version=1,
minor_version=1,
)
@pytest.fixture(name="migrated_config_entry")
def mock_migrated_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create mocked config entry."""
return MockConfigEntry(
title="Electric Kiwi",
domain=DOMAIN,
data={
"id": "123456",
"auth_implementation": DOMAIN,
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 60,
},
},
unique_id="123456",
version=1,
minor_version=2,
) )
@ -87,35 +160,10 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture(name="ek_auth") @pytest.fixture(name="ek_auth")
def electric_kiwi_auth() -> YieldFixture: def electric_kiwi_auth() -> Generator[AsyncMock]:
"""Patch access to electric kiwi access token.""" """Patch access to electric kiwi access token."""
with patch( with patch(
"homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" "homeassistant.components.electric_kiwi.api.ConfigEntryElectricKiwiAuth"
) as mock_auth: ) as mock_auth:
mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") mock_auth.return_value.async_get_access_token = AsyncMock("auth_token")
yield mock_auth yield mock_auth
@pytest.fixture(name="ek_api")
def ek_api() -> YieldFixture:
"""Mock ek api and return values."""
with patch(
"homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True
) as mock_ek_api:
mock_ek_api.return_value.customer_number = 123456
mock_ek_api.return_value.connection_id = 123456
mock_ek_api.return_value.set_active_session.return_value = None
mock_ek_api.return_value.get_hop_intervals.return_value = (
HopIntervals.from_dict(
load_json_value_fixture("hop_intervals.json", DOMAIN)
)
)
mock_ek_api.return_value.get_hop.return_value = Hop.from_dict(
load_json_value_fixture("get_hop.json", DOMAIN)
)
mock_ek_api.return_value.get_account_balance.return_value = (
AccountBalance.from_dict(
load_json_value_fixture("account_balance.json", DOMAIN)
)
)
yield mock_ek_api

View File

@ -1,28 +0,0 @@
{
"data": {
"connections": [
{
"hop_percentage": "3.5",
"id": 3,
"running_balance": "184.09",
"start_date": "2020-10-04",
"unbilled_days": 15
}
],
"last_billed_amount": "-66.31",
"last_billed_date": "2020-10-03",
"next_billing_date": "2020-11-03",
"is_prepay": "N",
"summary": {
"credits": "0.0",
"electricity_used": "184.09",
"other_charges": "0.00",
"payments": "-220.0"
},
"total_account_balance": "-102.22",
"total_billing_days": 30,
"total_running_balance": "184.09",
"type": "account_running_balance"
},
"status": 1
}

View File

@ -0,0 +1,43 @@
{
"data": {
"type": "account_summary",
"total_running_balance": "184.09",
"total_account_balance": "-102.22",
"total_billing_days": 31,
"next_billing_date": "2025-02-19",
"service_names": ["power"],
"services": {
"power": {
"connections": [
{
"id": 515363,
"running_balance": "12.98",
"unbilled_days": 5,
"hop_percentage": "11.2",
"start_date": "2025-01-19",
"service_label": "Power"
}
]
}
},
"date_to_pay": "",
"invoice_id": "",
"total_invoiced_charges": "",
"default_to_pay": "",
"invoice_exists": 1,
"display_date": "2025-01-19",
"last_billed_date": "2025-01-18",
"last_billed_amount": "-21.02",
"summary": {
"electricity_used": "12.98",
"other_charges": "0.00",
"payments": "0.00",
"credits": "0.00",
"mobile_charges": "0.00",
"broadband_charges": "0.00",
"addon_unbilled_charges": {}
},
"is_prepay": "N"
},
"status": 1
}

View File

@ -0,0 +1,73 @@
{
"data": {
"type": "connection",
"id": 515363,
"customer_id": 273941,
"customer_number": 34030646,
"icp_identifier": "00000000DDA",
"address": "",
"short_address": "",
"physical_address_unit": "",
"physical_address_number": "555",
"physical_address_street": "RACECOURSE ROAD",
"physical_address_suburb": "",
"physical_address_town": "Blah",
"physical_address_region": "Blah",
"physical_address_postcode": "0000",
"is_active": "Y",
"pricing_plan": {
"id": 51423,
"usage": "0.0000",
"fixed": "0.6000",
"usage_rate_inc_gst": "0.0000",
"supply_rate_inc_gst": "0.6900",
"plan_description": "MoveMaster Anytime Residential (Low User)",
"plan_type": "movemaster_tou",
"signup_price_plan_blurb": "Better rates every day during off-peak, and all day on weekends. Plus half price nights (11pm-7am) and our best solar buyback.",
"signup_price_plan_label": "MoveMaster",
"app_price_plan_label": "Your MoveMaster rates are...",
"solar_rate_excl_gst": "0.1250",
"solar_rate_incl_gst": "0.1438",
"pricing_type": "tou_plus",
"tou_plus": {
"fixed_rate_excl_gst": "0.6000",
"fixed_rate_incl_gst": "0.6900",
"interval_types": ["peak", "off_peak_shoulder", "off_peak_night"],
"peak": {
"price_excl_gst": "0.5390",
"price_incl_gst": "0.6199",
"display_text": {
"Weekdays": "7am-9am, 5pm-9pm"
},
"tou_plus_label": "Peak"
},
"off_peak_shoulder": {
"price_excl_gst": "0.3234",
"price_incl_gst": "0.3719",
"display_text": {
"Weekdays": "9am-5pm, 9pm-11pm",
"Weekends": "7am-11pm"
},
"tou_plus_label": "Off-peak shoulder"
},
"off_peak_night": {
"price_excl_gst": "0.2695",
"price_incl_gst": "0.3099",
"display_text": {
"Every day": "11pm-7am"
},
"tou_plus_label": "Off-peak night"
}
}
},
"hop": {
"start_time": "9:00 PM",
"end_time": "10:00 PM",
"interval_start": "43",
"interval_end": "44"
},
"start_date": "2022-03-03",
"end_date": "",
"property_type": "residential"
}
}

View File

@ -1,16 +1,18 @@
{ {
"data": { "data": {
"connection_id": "3", "type": "hop_customer",
"customer_number": 1000001, "customer_id": 123456,
"end": { "service_type": "electricity",
"end_time": "5:00 PM", "connection_id": 515363,
"interval": "34" "billing_id": 1247975,
},
"start": { "start": {
"start_time": "4:00 PM", "interval": "33",
"interval": "33" "start_time": "4:00 PM"
}, },
"type": "hop_customer" "end": {
"interval": "34",
"end_time": "5:00 PM"
}
}, },
"status": 1 "status": 1
} }

View File

@ -1,249 +1,250 @@
{ {
"data": { "data": {
"hop_duration": "60",
"type": "hop_intervals", "type": "hop_intervals",
"hop_duration": "60",
"intervals": { "intervals": {
"1": { "1": {
"active": 1, "start_time": "12:00 AM",
"end_time": "1:00 AM", "end_time": "1:00 AM",
"start_time": "12:00 AM" "active": 1
}, },
"2": { "2": {
"active": 1, "start_time": "12:30 AM",
"end_time": "1:30 AM", "end_time": "1:30 AM",
"start_time": "12:30 AM" "active": 1
}, },
"3": { "3": {
"active": 1, "start_time": "1:00 AM",
"end_time": "2:00 AM", "end_time": "2:00 AM",
"start_time": "1:00 AM" "active": 1
}, },
"4": { "4": {
"active": 1, "start_time": "1:30 AM",
"end_time": "2:30 AM", "end_time": "2:30 AM",
"start_time": "1:30 AM" "active": 1
}, },
"5": { "5": {
"active": 1, "start_time": "2:00 AM",
"end_time": "3:00 AM", "end_time": "3:00 AM",
"start_time": "2:00 AM" "active": 1
}, },
"6": { "6": {
"active": 1, "start_time": "2:30 AM",
"end_time": "3:30 AM", "end_time": "3:30 AM",
"start_time": "2:30 AM" "active": 1
}, },
"7": { "7": {
"active": 1, "start_time": "3:00 AM",
"end_time": "4:00 AM", "end_time": "4:00 AM",
"start_time": "3:00 AM" "active": 1
}, },
"8": { "8": {
"active": 1, "start_time": "3:30 AM",
"end_time": "4:30 AM", "end_time": "4:30 AM",
"start_time": "3:30 AM" "active": 1
}, },
"9": { "9": {
"active": 1, "start_time": "4:00 AM",
"end_time": "5:00 AM", "end_time": "5:00 AM",
"start_time": "4:00 AM" "active": 1
}, },
"10": { "10": {
"active": 1, "start_time": "4:30 AM",
"end_time": "5:30 AM", "end_time": "5:30 AM",
"start_time": "4:30 AM" "active": 1
}, },
"11": { "11": {
"active": 1, "start_time": "5:00 AM",
"end_time": "6:00 AM", "end_time": "6:00 AM",
"start_time": "5:00 AM" "active": 1
}, },
"12": { "12": {
"active": 1, "start_time": "5:30 AM",
"end_time": "6:30 AM", "end_time": "6:30 AM",
"start_time": "5:30 AM" "active": 1
}, },
"13": { "13": {
"active": 1, "start_time": "6:00 AM",
"end_time": "7:00 AM", "end_time": "7:00 AM",
"start_time": "6:00 AM" "active": 1
}, },
"14": { "14": {
"active": 1, "start_time": "6:30 AM",
"end_time": "7:30 AM", "end_time": "7:30 AM",
"start_time": "6:30 AM" "active": 0
}, },
"15": { "15": {
"active": 1, "start_time": "7:00 AM",
"end_time": "8:00 AM", "end_time": "8:00 AM",
"start_time": "7:00 AM" "active": 0
}, },
"16": { "16": {
"active": 1, "start_time": "7:30 AM",
"end_time": "8:30 AM", "end_time": "8:30 AM",
"start_time": "7:30 AM" "active": 0
}, },
"17": { "17": {
"active": 1, "start_time": "8:00 AM",
"end_time": "9:00 AM", "end_time": "9:00 AM",
"start_time": "8:00 AM" "active": 0
}, },
"18": { "18": {
"active": 1, "start_time": "8:30 AM",
"end_time": "9:30 AM", "end_time": "9:30 AM",
"start_time": "8:30 AM" "active": 0
}, },
"19": { "19": {
"active": 1, "start_time": "9:00 AM",
"end_time": "10:00 AM", "end_time": "10:00 AM",
"start_time": "9:00 AM" "active": 1
}, },
"20": { "20": {
"active": 1, "start_time": "9:30 AM",
"end_time": "10:30 AM", "end_time": "10:30 AM",
"start_time": "9:30 AM" "active": 1
}, },
"21": { "21": {
"active": 1, "start_time": "10:00 AM",
"end_time": "11:00 AM", "end_time": "11:00 AM",
"start_time": "10:00 AM" "active": 1
}, },
"22": { "22": {
"active": 1, "start_time": "10:30 AM",
"end_time": "11:30 AM", "end_time": "11:30 AM",
"start_time": "10:30 AM" "active": 1
}, },
"23": { "23": {
"active": 1, "start_time": "11:00 AM",
"end_time": "12:00 PM", "end_time": "12:00 PM",
"start_time": "11:00 AM" "active": 1
}, },
"24": { "24": {
"active": 1, "start_time": "11:30 AM",
"end_time": "12:30 PM", "end_time": "12:30 PM",
"start_time": "11:30 AM" "active": 1
}, },
"25": { "25": {
"active": 1, "start_time": "12:00 PM",
"end_time": "1:00 PM", "end_time": "1:00 PM",
"start_time": "12:00 PM" "active": 1
}, },
"26": { "26": {
"active": 1, "start_time": "12:30 PM",
"end_time": "1:30 PM", "end_time": "1:30 PM",
"start_time": "12:30 PM" "active": 1
}, },
"27": { "27": {
"active": 1, "start_time": "1:00 PM",
"end_time": "2:00 PM", "end_time": "2:00 PM",
"start_time": "1:00 PM" "active": 1
}, },
"28": { "28": {
"active": 1, "start_time": "1:30 PM",
"end_time": "2:30 PM", "end_time": "2:30 PM",
"start_time": "1:30 PM" "active": 1
}, },
"29": { "29": {
"active": 1, "start_time": "2:00 PM",
"end_time": "3:00 PM", "end_time": "3:00 PM",
"start_time": "2:00 PM" "active": 1
}, },
"30": { "30": {
"active": 1, "start_time": "2:30 PM",
"end_time": "3:30 PM", "end_time": "3:30 PM",
"start_time": "2:30 PM" "active": 1
}, },
"31": { "31": {
"active": 1, "start_time": "3:00 PM",
"end_time": "4:00 PM", "end_time": "4:00 PM",
"start_time": "3:00 PM" "active": 1
}, },
"32": { "32": {
"active": 1, "start_time": "3:30 PM",
"end_time": "4:30 PM", "end_time": "4:30 PM",
"start_time": "3:30 PM" "active": 1
}, },
"33": { "33": {
"active": 1, "start_time": "4:00 PM",
"end_time": "5:00 PM", "end_time": "5:00 PM",
"start_time": "4:00 PM" "active": 1
}, },
"34": { "34": {
"active": 1, "start_time": "4:30 PM",
"end_time": "5:30 PM", "end_time": "5:30 PM",
"start_time": "4:30 PM" "active": 0
}, },
"35": { "35": {
"active": 1, "start_time": "5:00 PM",
"end_time": "6:00 PM", "end_time": "6:00 PM",
"start_time": "5:00 PM" "active": 0
}, },
"36": { "36": {
"active": 1, "start_time": "5:30 PM",
"end_time": "6:30 PM", "end_time": "6:30 PM",
"start_time": "5:30 PM" "active": 0
}, },
"37": { "37": {
"active": 1, "start_time": "6:00 PM",
"end_time": "7:00 PM", "end_time": "7:00 PM",
"start_time": "6:00 PM" "active": 0
}, },
"38": { "38": {
"active": 1, "start_time": "6:30 PM",
"end_time": "7:30 PM", "end_time": "7:30 PM",
"start_time": "6:30 PM" "active": 0
}, },
"39": { "39": {
"active": 1, "start_time": "7:00 PM",
"end_time": "8:00 PM", "end_time": "8:00 PM",
"start_time": "7:00 PM" "active": 0
}, },
"40": { "40": {
"active": 1, "start_time": "7:30 PM",
"end_time": "8:30 PM", "end_time": "8:30 PM",
"start_time": "7:30 PM" "active": 0
}, },
"41": { "41": {
"active": 1, "start_time": "8:00 PM",
"end_time": "9:00 PM", "end_time": "9:00 PM",
"start_time": "8:00 PM" "active": 0
}, },
"42": { "42": {
"active": 1, "start_time": "8:30 PM",
"end_time": "9:30 PM", "end_time": "9:30 PM",
"start_time": "8:30 PM" "active": 0
}, },
"43": { "43": {
"active": 1, "start_time": "9:00 PM",
"end_time": "10:00 PM", "end_time": "10:00 PM",
"start_time": "9:00 PM" "active": 1
}, },
"44": { "44": {
"active": 1, "start_time": "9:30 PM",
"end_time": "10:30 PM", "end_time": "10:30 PM",
"start_time": "9:30 PM" "active": 1
}, },
"45": { "45": {
"active": 1, "start_time": "10:00 PM",
"end_time": "11:00 AM", "end_time": "11:00 PM",
"start_time": "10:00 PM" "active": 1
}, },
"46": { "46": {
"active": 1, "start_time": "10:30 PM",
"end_time": "11:30 PM", "end_time": "11:30 PM",
"start_time": "10:30 PM" "active": 1
}, },
"47": { "47": {
"active": 1, "start_time": "11:00 PM",
"end_time": "12:00 AM", "end_time": "12:00 AM",
"start_time": "11:00 PM" "active": 1
}, },
"48": { "48": {
"active": 1, "start_time": "11:30 PM",
"end_time": "12:30 AM", "end_time": "12:30 AM",
"start_time": "11:30 PM" "active": 0
} }
} },
"service_type": "electricity"
}, },
"status": 1 "status": 1
} }

View File

@ -0,0 +1,23 @@
{
"data": {
"data": {
"type": "session",
"avatar": [],
"customer_number": 123456,
"customer_name": "Joe Dirt",
"email": "joe@dirt.kiwi",
"customer_status": "Y",
"services": [
{
"service": "Electricity",
"identifier": "00000000DDA",
"is_primary_service": true,
"service_status": "Y"
}
],
"res_partner_id": 285554,
"nuid": "EK_GUID"
}
},
"status": 1
}

View File

@ -0,0 +1,16 @@
{
"data": {
"data": {
"type": "session",
"avatar": [],
"customer_number": 123456,
"customer_name": "Joe Dirt",
"email": "joe@dirt.kiwi",
"customer_status": "Y",
"services": [],
"res_partner_id": 285554,
"nuid": "EK_GUID"
}
},
"status": 1
}

View File

@ -3,70 +3,40 @@
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock
from electrickiwi_api.exceptions import ApiException
import pytest import pytest
from homeassistant import config_entries
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.electric_kiwi.const import ( from homeassistant.components.electric_kiwi.const import (
DOMAIN, DOMAIN,
OAUTH2_AUTHORIZE, OAUTH2_AUTHORIZE,
OAUTH2_TOKEN, OAUTH2_TOKEN,
SCOPE_VALUES, SCOPE_VALUES,
) )
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component
from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI from .conftest import CLIENT_ID, REDIRECT_URI
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.usefixtures("current_request_with_host", "electrickiwi_api")
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup application credentials component."""
await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
async def test_config_flow_no_credentials(hass: HomeAssistant) -> None:
"""Test config flow base case with no credentials registered."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "missing_credentials"
@pytest.mark.usefixtures("current_request_with_host")
async def test_full_flow( async def test_full_flow(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
setup_credentials: None,
mock_setup_entry: AsyncMock, mock_setup_entry: AsyncMock,
) -> None: ) -> None:
"""Check full flow.""" """Check full flow."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} DOMAIN, context={"source": SOURCE_USER}
) )
state = config_entry_oauth2_flow._encode_jwt( state = config_entry_oauth2_flow._encode_jwt(
hass, hass,
@ -76,13 +46,13 @@ async def test_full_flow(
}, },
) )
URL_SCOPE = SCOPE_VALUES.replace(" ", "+") url_scope = SCOPE_VALUES.replace(" ", "+")
assert result["url"] == ( assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}" f"&redirect_uri={REDIRECT_URI}"
f"&state={state}" f"&state={state}"
f"&scope={URL_SCOPE}" f"&scope={url_scope}"
) )
client = await hass_client_no_auth() client = await hass_client_no_auth()
@ -90,6 +60,7 @@ async def test_full_flow(
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.clear_requests()
aioclient_mock.post( aioclient_mock.post(
OAUTH2_TOKEN, OAUTH2_TOKEN,
json={ json={
@ -106,20 +77,73 @@ async def test_full_flow(
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host")
async def test_flow_failure(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
electrickiwi_api: AsyncMock,
) -> None:
"""Check failure on creation of entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URI,
},
)
url_scope = SCOPE_VALUES.replace(" ", "+")
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&state={state}"
f"&scope={url_scope}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
electrickiwi_api.get_active_session.side_effect = ApiException()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "connection_error"
@pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("current_request_with_host")
async def test_existing_entry( async def test_existing_entry(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
setup_credentials: None, migrated_config_entry: MockConfigEntry,
config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Check existing entry.""" """Check existing entry."""
config_entry.add_to_hass(hass) migrated_config_entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} DOMAIN, context={"source": SOURCE_USER, "entry_id": DOMAIN}
) )
state = config_entry_oauth2_flow._encode_jwt( state = config_entry_oauth2_flow._encode_jwt(
@ -145,7 +169,9 @@ async def test_existing_entry(
}, },
) )
await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@ -154,13 +180,13 @@ async def test_reauthentication(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
mock_setup_entry: MagicMock, mock_setup_entry: AsyncMock,
config_entry: MockConfigEntry, migrated_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None: ) -> None:
"""Test Electric Kiwi reauthentication.""" """Test Electric Kiwi reauthentication."""
config_entry.add_to_hass(hass) migrated_config_entry.add_to_hass(hass)
result = await config_entry.start_reauth_flow(hass)
result = await migrated_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
@ -189,8 +215,11 @@ async def test_reauthentication(
}, },
) )
await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reauth_successful"

View File

@ -0,0 +1,135 @@
"""Test the Electric Kiwi init."""
import http
from unittest.mock import AsyncMock, patch
from aiohttp import RequestInfo
from aiohttp.client_exceptions import ClientResponseError
from electrickiwi_api.exceptions import ApiException, AuthException
import pytest
from homeassistant.components.electric_kiwi.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import init_integration
from tests.common import MockConfigEntry
async def test_async_setup_entry(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test a successful setup entry and unload of entry."""
await init_integration(hass, config_entry)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_async_setup_multiple_entries(
hass: HomeAssistant,
config_entry: MockConfigEntry,
config_entry2: MockConfigEntry,
) -> None:
"""Test a successful setup and unload of multiple entries."""
for entry in (config_entry, config_entry2):
await init_integration(hass, entry)
assert len(hass.config_entries.async_entries(DOMAIN)) == 2
for entry in (config_entry, config_entry2):
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("status", "expected_state"),
[
(
http.HTTPStatus.UNAUTHORIZED,
ConfigEntryState.SETUP_ERROR,
),
(
http.HTTPStatus.INTERNAL_SERVER_ERROR,
ConfigEntryState.SETUP_RETRY,
),
],
ids=["failure_requires_reauth", "transient_failure"],
)
async def test_refresh_token_validity_failures(
hass: HomeAssistant,
config_entry: MockConfigEntry,
status: http.HTTPStatus,
expected_state: ConfigEntryState,
) -> None:
"""Test token refresh failure status."""
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=ClientResponseError(
RequestInfo("", "POST", {}, ""), None, status=status
),
) as mock_async_ensure_token_valid:
await init_integration(hass, config_entry)
mock_async_ensure_token_valid.assert_called_once()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert entries[0].state is expected_state
async def test_unique_id_migration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that the unique ID is migrated to the customer number."""
config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
SENSOR_DOMAIN, DOMAIN, "123456_515363_sensor", config_entry=config_entry
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
new_entry = hass.config_entries.async_get_entry(config_entry.entry_id)
assert new_entry.minor_version == 2
assert new_entry.unique_id == "123456"
entity_entry = entity_registry.async_get(
"sensor.electric_kiwi_123456_515363_sensor"
)
assert entity_entry.unique_id == "123456_00000000DDA_sensor"
async def test_unique_id_migration_failure(
hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock
) -> None:
"""Test that the unique ID is migrated to the customer number."""
electrickiwi_api.set_active_session.side_effect = ApiException()
await init_integration(hass, config_entry)
assert config_entry.minor_version == 1
assert config_entry.unique_id == DOMAIN
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_unique_id_migration_auth_failure(
hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock
) -> None:
"""Test that the unique ID is migrated to the customer number."""
electrickiwi_api.set_active_session.side_effect = AuthException()
await init_integration(hass, config_entry)
assert config_entry.minor_version == 1
assert config_entry.unique_id == DOMAIN
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR

View File

@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .conftest import ComponentSetup, YieldFixture from . import init_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -47,10 +47,9 @@ def restore_timezone():
async def test_hop_sensors( async def test_hop_sensors(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
ek_api: YieldFixture, electrickiwi_api: Mock,
ek_auth: YieldFixture, ek_auth: AsyncMock,
entity_registry: EntityRegistry, entity_registry: EntityRegistry,
component_setup: ComponentSetup,
sensor: str, sensor: str,
sensor_state: str, sensor_state: str,
) -> None: ) -> None:
@ -61,7 +60,7 @@ async def test_hop_sensors(
sensor state should be set to today at 4pm or if now is past 4pm, sensor state should be set to today at 4pm or if now is past 4pm,
then tomorrow at 4pm. then tomorrow at 4pm.
""" """
assert await component_setup() await init_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
entity = entity_registry.async_get(sensor) entity = entity_registry.async_get(sensor)
@ -70,8 +69,7 @@ async def test_hop_sensors(
state = hass.states.get(sensor) state = hass.states.get(sensor)
assert state assert state
api = ek_api(Mock()) hop_data = await electrickiwi_api.get_hop()
hop_data = await api.get_hop()
value = _check_and_move_time(hop_data, sensor_state) value = _check_and_move_time(hop_data, sensor_state)
@ -98,20 +96,19 @@ async def test_hop_sensors(
), ),
( (
"sensor.next_billing_date", "sensor.next_billing_date",
"2020-11-03T00:00:00", "2025-02-19T00:00:00",
SensorDeviceClass.DATE, SensorDeviceClass.DATE,
None, None,
), ),
("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), ("sensor.hour_of_power_savings", "11.2", None, SensorStateClass.MEASUREMENT),
], ],
) )
async def test_account_sensors( async def test_account_sensors(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
ek_api: YieldFixture, electrickiwi_api: AsyncMock,
ek_auth: YieldFixture, ek_auth: AsyncMock,
entity_registry: EntityRegistry, entity_registry: EntityRegistry,
component_setup: ComponentSetup,
sensor: str, sensor: str,
sensor_state: str, sensor_state: str,
device_class: str, device_class: str,
@ -119,7 +116,7 @@ async def test_account_sensors(
) -> None: ) -> None:
"""Test Account sensors for the Electric Kiwi integration.""" """Test Account sensors for the Electric Kiwi integration."""
assert await component_setup() await init_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
entity = entity_registry.async_get(sensor) entity = entity_registry.async_get(sensor)
@ -133,9 +130,9 @@ async def test_account_sensors(
assert state.attributes.get(ATTR_STATE_CLASS) == state_class assert state.attributes.get(ATTR_STATE_CLASS) == state_class
async def test_check_and_move_time(ek_api: AsyncMock) -> None: async def test_check_and_move_time(electrickiwi_api: AsyncMock) -> None:
"""Test correct time is returned depending on time of day.""" """Test correct time is returned depending on time of day."""
hop = await ek_api(Mock()).get_hop() hop = await electrickiwi_api.get_hop()
test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE)
dt_util.set_default_time_zone(TEST_TIMEZONE) dt_util.set_default_time_zone(TEST_TIMEZONE)