Test storage save and load for evohome (#122510)

* test storage save and load

* fix bug exposed by test

* refactor test

* add JSON for test account/location

* create helpers to load JSON

* refactor test

* baseline refactor

* tweak

* update requiremenst

* rationalise code

* remove conditional in test

* refactor test

* mypy fix

* tweak tests

* working test

* working test 4

* working test 5

* add typed dicts

* working dtms

* lint

* fix dtm asserts

* doc strings

* list

* tweak conditional

* tweak test data sets to extend coverage

* leverage conftest.py for subsequent tests

* revert test storage

* revert part two

* rename symbols

* remove anachronism

* stop unwanted DNS lookup

* Clean up type ignores

* Format

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
David Bonnes 2024-08-03 10:41:30 +02:00 committed by GitHub
parent 6684f61a54
commit bb31fc1ec7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 964 additions and 0 deletions

View File

@ -433,6 +433,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/evil_genius_labs/ @balloob
/tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb
/tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26 @baqs
/tests/components/ezviz/ @RenierM26 @baqs
/homeassistant/components/faa_delays/ @ntilley905

View File

@ -717,6 +717,9 @@ eternalegypt==0.0.16
# homeassistant.components.eufylife_ble
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==0.4.20
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18

View File

@ -0,0 +1 @@
"""The tests for the evohome integration."""

View File

@ -0,0 +1,111 @@
"""Fixtures and helpers for the evohome tests."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Final
from unittest.mock import MagicMock, patch
from aiohttp import ClientSession
from evohomeasync2 import EvohomeClient
from evohomeasync2.broker import Broker
import pytest
from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import ACCESS_TOKEN, REFRESH_TOKEN
from tests.common import load_json_array_fixture, load_json_object_fixture
TEST_CONFIG: Final = {
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
}
def user_account_config_fixture() -> JsonObjectType:
"""Load JSON for the config of a user's account."""
return load_json_object_fixture("user_account.json", DOMAIN)
def user_locations_config_fixture() -> JsonArrayType:
"""Load JSON for the config of a user's installation (a list of locations)."""
return load_json_array_fixture("user_locations.json", DOMAIN)
def location_status_fixture(loc_id: str) -> JsonObjectType:
"""Load JSON for the status of a specific location."""
return load_json_object_fixture(f"status_{loc_id}.json", DOMAIN)
def dhw_schedule_fixture() -> JsonObjectType:
"""Load JSON for the schedule of a domesticHotWater zone."""
return load_json_object_fixture("schedule_dhw.json", DOMAIN)
def zone_schedule_fixture() -> JsonObjectType:
"""Load JSON for the schedule of a temperatureZone zone."""
return load_json_object_fixture("schedule_zone.json", DOMAIN)
async def mock_get(
self: Broker, url: str, **kwargs: Any
) -> JsonArrayType | JsonObjectType:
"""Return the JSON for a HTTP get of a given URL."""
# a proxy for the behaviour of the real web API
if self.refresh_token is None:
self.refresh_token = f"new_{REFRESH_TOKEN}"
if self.access_token_expires is None or self.access_token_expires < datetime.now():
self.access_token = f"new_{ACCESS_TOKEN}"
self.access_token_expires = datetime.now() + timedelta(minutes=30)
# assume a valid GET, and return the JSON for that web API
if url == "userAccount": # userAccount
return user_account_config_fixture()
if url.startswith("location"):
if "installationInfo" in url: # location/installationInfo?userId={id}
return user_locations_config_fixture()
if "location" in url: # location/{id}/status
return location_status_fixture("2738909")
elif "schedule" in url:
if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule
return dhw_schedule_fixture()
if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule
return zone_schedule_fixture()
pytest.xfail(f"Unexpected URL: {url}")
@patch("evohomeasync2.broker.Broker.get", mock_get)
async def setup_evohome(hass: HomeAssistant, test_config: dict[str, str]) -> MagicMock:
"""Set up the evohome integration and return its client.
The class is mocked here to check the client was instantiated with the correct args.
"""
with (
patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client,
patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None),
):
mock_client.side_effect = EvohomeClient
assert await async_setup_component(hass, DOMAIN, {DOMAIN: test_config})
await hass.async_block_till_done()
mock_client.assert_called_once()
assert mock_client.call_args.args[0] == test_config[CONF_USERNAME]
assert mock_client.call_args.args[1] == test_config[CONF_PASSWORD]
assert isinstance(mock_client.call_args.kwargs["session"], ClientSession)
assert mock_client.account_info is not None
return mock_client

View File

@ -0,0 +1,10 @@
"""Constants for the evohome tests."""
from __future__ import annotations
from typing import Final
ACCESS_TOKEN: Final = "at_1dc7z657UKzbhKA..."
REFRESH_TOKEN: Final = "rf_jg68ZCKYdxEI3fF..."
SESSION_ID: Final = "F7181186..."
USERNAME: Final = "test_user@gmail.com"

View File

@ -0,0 +1,81 @@
{
"dailySchedules": [
{
"dayOfWeek": "Monday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "08:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "22:30:00" }
]
},
{
"dayOfWeek": "Tuesday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "08:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "22:30:00" }
]
},
{
"dayOfWeek": "Wednesday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "08:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "22:30:00" }
]
},
{
"dayOfWeek": "Thursday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "08:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "22:30:00" }
]
},
{
"dayOfWeek": "Friday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "08:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "22:30:00" }
]
},
{
"dayOfWeek": "Saturday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "09:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Sunday",
"switchpoints": [
{ "dhwState": "On", "timeOfDay": "06:30:00" },
{ "dhwState": "Off", "timeOfDay": "09:30:00" },
{ "dhwState": "On", "timeOfDay": "12:00:00" },
{ "dhwState": "Off", "timeOfDay": "13:00:00" },
{ "dhwState": "On", "timeOfDay": "16:30:00" },
{ "dhwState": "Off", "timeOfDay": "23:00:00" }
]
}
]
}

View File

@ -0,0 +1,67 @@
{
"dailySchedules": [
{
"dayOfWeek": "Monday",
"switchpoints": [
{ "heatSetpoint": 18.1, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:00:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Tuesday",
"switchpoints": [
{ "heatSetpoint": 18.1, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:00:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Wednesday",
"switchpoints": [
{ "heatSetpoint": 18.1, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:00:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Thursday",
"switchpoints": [
{ "heatSetpoint": 18.1, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:00:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Friday",
"switchpoints": [
{ "heatSetpoint": 18.1, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:00:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Saturday",
"switchpoints": [
{ "heatSetpoint": 18.5, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:30:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
},
{
"dayOfWeek": "Sunday",
"switchpoints": [
{ "heatSetpoint": 18.5, "timeOfDay": "07:00:00" },
{ "heatSetpoint": 16.0, "timeOfDay": "08:30:00" },
{ "heatSetpoint": 18.6, "timeOfDay": "22:10:00" },
{ "heatSetpoint": 15.9, "timeOfDay": "23:00:00" }
]
}
]
}

View File

@ -0,0 +1,125 @@
{
"locationId": "2738909",
"gateways": [
{
"gatewayId": "2499896",
"temperatureControlSystems": [
{
"systemId": "3432522",
"zones": [
{
"zoneId": "3432521",
"name": "Dead Zone",
"temperatureStatus": { "isAvailable": false },
"setpointStatus": {
"targetHeatTemperature": 17.0,
"setpointMode": "FollowSchedule"
},
"activeFaults": []
},
{
"zoneId": "3432576",
"name": "Main Room",
"temperatureStatus": { "temperature": 19.0, "isAvailable": true },
"setpointStatus": {
"targetHeatTemperature": 17.0,
"setpointMode": "PermanentOverride"
},
"activeFaults": [
{
"faultType": "TempZoneActuatorCommunicationLost",
"since": "2022-03-02T15:56:01"
}
]
},
{
"zoneId": "3432577",
"name": "Front Room",
"temperatureStatus": { "temperature": 19.0, "isAvailable": true },
"setpointStatus": {
"targetHeatTemperature": 21.0,
"setpointMode": "TemporaryOverride",
"until": "2022-03-07T19:00:00Z"
},
"activeFaults": [
{
"faultType": "TempZoneActuatorLowBattery",
"since": "2022-03-02T04:50:20"
}
]
},
{
"zoneId": "3432578",
"temperatureStatus": { "temperature": 20.0, "isAvailable": true },
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 17.0,
"setpointMode": "FollowSchedule"
},
"name": "Kitchen"
},
{
"zoneId": "3432579",
"temperatureStatus": { "temperature": 20.0, "isAvailable": true },
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 16.0,
"setpointMode": "FollowSchedule"
},
"name": "Bathroom Dn"
},
{
"zoneId": "3432580",
"temperatureStatus": { "temperature": 21.0, "isAvailable": true },
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 16.0,
"setpointMode": "FollowSchedule"
},
"name": "Main Bedroom"
},
{
"zoneId": "3449703",
"temperatureStatus": { "temperature": 19.5, "isAvailable": true },
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 17.0,
"setpointMode": "FollowSchedule"
},
"name": "Kids Room"
},
{
"zoneId": "3449740",
"temperatureStatus": { "temperature": 21.5, "isAvailable": true },
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 16.5,
"setpointMode": "FollowSchedule"
},
"name": ""
},
{
"zoneId": "3450733",
"temperatureStatus": { "temperature": 19.5, "isAvailable": true },
"activeFaults": [],
"setpointStatus": {
"targetHeatTemperature": 14.0,
"setpointMode": "PermanentOverride"
},
"name": "Spare Room"
}
],
"dhw": {
"dhwId": "3933910",
"temperatureStatus": { "temperature": 23.0, "isAvailable": true },
"stateStatus": { "state": "Off", "mode": "PermanentOverride" },
"activeFaults": []
},
"activeFaults": [],
"systemModeStatus": { "mode": "AutoWithEco", "isPermanent": true }
}
],
"activeFaults": []
}
]
}

View File

@ -0,0 +1,11 @@
{
"userId": "2263181",
"username": "user_2263181@gmail.com",
"firstname": "John",
"lastname": "Smith",
"streetAddress": "1 Main Street",
"city": "London",
"postcode": "E1 1AA",
"country": "UnitedKingdom",
"language": "enGB"
}

View File

@ -0,0 +1,346 @@
[
{
"locationInfo": {
"locationId": "2738909",
"name": "My Home",
"streetAddress": "1 Main Street",
"city": "London",
"country": "UnitedKingdom",
"postcode": "E1 1AA",
"locationType": "Residential",
"useDaylightSaveSwitching": true,
"timeZone": {
"timeZoneId": "GMTStandardTime",
"displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London",
"offsetMinutes": 0,
"currentOffsetMinutes": 60,
"supportsDaylightSaving": true
},
"locationOwner": {
"userId": "2263181",
"username": "user_2263181@gmail.com",
"firstname": "John",
"lastname": "Smith"
}
},
"gateways": [
{
"gatewayInfo": {
"gatewayId": "2499896",
"mac": "00D02DEE0000",
"crc": "1234",
"isWiFi": false
},
"temperatureControlSystems": [
{
"systemId": "3432522",
"modelType": "EvoTouch",
"zones": [
{
"zoneId": "3432521",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Dead Zone",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3432576",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Main Room",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3432577",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Front Room",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3432578",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Kitchen",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3432579",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Bathroom Dn",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3432580",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Main Bedroom",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3449703",
"modelType": "HeatingZone",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Kids Room",
"zoneType": "RadiatorZone"
},
{
"zoneId": "3449740",
"modelType": "Unknown",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "",
"zoneType": "Unknown"
},
{
"zoneId": "3450733",
"modelType": "xx",
"setpointCapabilities": {
"maxHeatSetpoint": 35.0,
"minHeatSetpoint": 5.0,
"valueResolution": 0.5,
"canControlHeat": true,
"canControlCool": false,
"allowedSetpointModes": [
"PermanentOverride",
"FollowSchedule",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilities": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00",
"setpointValueResolution": 0.5
},
"name": "Spare Room",
"zoneType": "xx"
}
],
"dhw": {
"dhwId": "3933910",
"dhwStateCapabilitiesResponse": {
"allowedStates": ["On", "Off"],
"allowedModes": [
"FollowSchedule",
"PermanentOverride",
"TemporaryOverride"
],
"maxDuration": "1.00:00:00",
"timingResolution": "00:10:00"
},
"scheduleCapabilitiesResponse": {
"maxSwitchpointsPerDay": 6,
"minSwitchpointsPerDay": 1,
"timingResolution": "00:10:00"
}
},
"allowedSystemModes": [
{
"systemMode": "HeatingOff",
"canBePermanent": true,
"canBeTemporary": false
},
{
"systemMode": "Auto",
"canBePermanent": true,
"canBeTemporary": false
},
{
"systemMode": "AutoWithReset",
"canBePermanent": true,
"canBeTemporary": false
},
{
"systemMode": "AutoWithEco",
"canBePermanent": true,
"canBeTemporary": true,
"maxDuration": "1.00:00:00",
"timingResolution": "01:00:00",
"timingMode": "Duration"
},
{
"systemMode": "Away",
"canBePermanent": true,
"canBeTemporary": true,
"maxDuration": "99.00:00:00",
"timingResolution": "1.00:00:00",
"timingMode": "Period"
},
{
"systemMode": "DayOff",
"canBePermanent": true,
"canBeTemporary": true,
"maxDuration": "99.00:00:00",
"timingResolution": "1.00:00:00",
"timingMode": "Period"
},
{
"systemMode": "Custom",
"canBePermanent": true,
"canBeTemporary": true,
"maxDuration": "99.00:00:00",
"timingResolution": "1.00:00:00",
"timingMode": "Period"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,208 @@
"""The tests for evohome storage load & save."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Final, NotRequired, TypedDict
import pytest
from homeassistant.components.evohome import (
CONF_PASSWORD,
CONF_USERNAME,
DOMAIN,
STORAGE_KEY,
STORAGE_VER,
dt_aware_to_naive,
)
from homeassistant.core import HomeAssistant
import homeassistant.util.dt as dt_util
from .conftest import setup_evohome
from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME
class _SessionDataT(TypedDict):
sessionId: str
class _TokenStoreT(TypedDict):
username: str
refresh_token: str
access_token: str
access_token_expires: str # 2024-07-27T23:57:30+01:00
user_data: NotRequired[_SessionDataT]
class _EmptyStoreT(TypedDict):
pass
SZ_USERNAME: Final = "username"
SZ_REFRESH_TOKEN: Final = "refresh_token"
SZ_ACCESS_TOKEN: Final = "access_token"
SZ_ACCESS_TOKEN_EXPIRES: Final = "access_token_expires"
SZ_USER_DATA: Final = "user_data"
def dt_pair(dt_dtm: datetime) -> tuple[datetime, str]:
"""Return a datetime without milliseconds and its string representation."""
dt_str = dt_dtm.isoformat(timespec="seconds") # e.g. 2024-07-28T00:57:29+01:00
return dt_util.parse_datetime(dt_str, raise_on_error=True), dt_str
ACCESS_TOKEN_EXP_DTM, ACCESS_TOKEN_EXP_STR = dt_pair(dt_util.now() + timedelta(hours=1))
USERNAME_DIFF: Final = f"not_{USERNAME}"
USERNAME_SAME: Final = USERNAME
TEST_CONFIG: Final = {
CONF_USERNAME: USERNAME_SAME,
CONF_PASSWORD: "password",
}
TEST_DATA: Final[dict[str, _TokenStoreT]] = {
"sans_session_id": {
SZ_USERNAME: USERNAME_SAME,
SZ_REFRESH_TOKEN: REFRESH_TOKEN,
SZ_ACCESS_TOKEN: ACCESS_TOKEN,
SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR,
},
"with_session_id": {
SZ_USERNAME: USERNAME_SAME,
SZ_REFRESH_TOKEN: REFRESH_TOKEN,
SZ_ACCESS_TOKEN: ACCESS_TOKEN,
SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR,
SZ_USER_DATA: {"sessionId": SESSION_ID},
},
}
TEST_DATA_NULL: Final[dict[str, _EmptyStoreT | None]] = {
"store_is_absent": None,
"store_was_reset": {},
}
DOMAIN_STORAGE_BASE: Final = {
"version": STORAGE_VER,
"minor_version": 1,
"key": STORAGE_KEY,
}
@pytest.mark.parametrize("idx", TEST_DATA_NULL)
async def test_auth_tokens_null(
hass: HomeAssistant,
hass_storage: dict[str, Any],
idx: str,
) -> None:
"""Test loading/saving authentication tokens when no cached tokens in the store."""
hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA_NULL[idx]}
mock_client = await setup_evohome(hass, TEST_CONFIG)
# Confirm client was instantiated without tokens, as cache was empty...
assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg
# Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"]
assert data[SZ_USERNAME] == USERNAME_SAME
assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}"
assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}"
assert (
dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True)
> dt_util.now()
)
@pytest.mark.parametrize("idx", TEST_DATA)
async def test_auth_tokens_same(
hass: HomeAssistant, hass_storage: dict[str, Any], idx: str
) -> None:
"""Test loading/saving authentication tokens when matching username."""
hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]}
mock_client = await setup_evohome(hass, TEST_CONFIG)
# Confirm client was instantiated with the cached tokens...
assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN
assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN
assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN_EXPIRES] == dt_aware_to_naive(
ACCESS_TOKEN_EXP_DTM
)
# Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"]
assert data[SZ_USERNAME] == USERNAME_SAME
assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN
assert data[SZ_ACCESS_TOKEN] == ACCESS_TOKEN
assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM
@pytest.mark.parametrize("idx", TEST_DATA)
async def test_auth_tokens_past(
hass: HomeAssistant, hass_storage: dict[str, Any], idx: str
) -> None:
"""Test loading/saving authentication tokens with matching username, but expired."""
dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1))
# make this access token have expired in the past...
test_data = TEST_DATA[idx].copy() # shallow copy is OK here
test_data[SZ_ACCESS_TOKEN_EXPIRES] = dt_str
hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data}
mock_client = await setup_evohome(hass, TEST_CONFIG)
# Confirm client was instantiated with the cached tokens...
assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN
assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN
assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN_EXPIRES] == dt_aware_to_naive(
dt_dtm
)
# Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"]
assert data[SZ_USERNAME] == USERNAME_SAME
assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN
assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}"
assert (
dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True)
> dt_util.now()
)
@pytest.mark.parametrize("idx", TEST_DATA)
async def test_auth_tokens_diff(
hass: HomeAssistant, hass_storage: dict[str, Any], idx: str
) -> None:
"""Test loading/saving authentication tokens when unmatched username."""
hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]}
mock_client = await setup_evohome(
hass, TEST_CONFIG | {CONF_USERNAME: USERNAME_DIFF}
)
# Confirm client was instantiated without tokens, as username was different...
assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg
# Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"]
assert data[SZ_USERNAME] == USERNAME_DIFF
assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}"
assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}"
assert (
dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True)
> dt_util.now()
)