mirror of
https://github.com/home-assistant/core.git
synced 2026-09-20 16:53:19 +01:00
548 lines
16 KiB
Python
548 lines
16 KiB
Python
"""Tests for the services provided by the EnergyZero integration."""
|
|
|
|
from datetime import UTC, date, datetime
|
|
import re
|
|
from unittest.mock import AsyncMock
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from energyzero import EnergyPrices, EnergyZeroNoDataError, PriceType, TimeRange
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.energyzero.const import DOMAIN
|
|
from homeassistant.components.energyzero.services import (
|
|
ATTR_CONFIG_ENTRY,
|
|
ENERGY_SERVICE_NAME,
|
|
GAS_SERVICE_NAME,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
|
|
from tests.common import MockConfigEntry
|
|
|
|
pytestmark = pytest.mark.freeze_time("2026-04-10 20:32:59")
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "service_data"),
|
|
[
|
|
(
|
|
GAS_SERVICE_NAME,
|
|
{"incl_vat": False},
|
|
),
|
|
(
|
|
ENERGY_SERVICE_NAME,
|
|
{"incl_vat": True},
|
|
),
|
|
],
|
|
)
|
|
async def test_service(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
snapshot: SnapshotAssertion,
|
|
service: str,
|
|
service_data: dict[str, str | bool],
|
|
) -> None:
|
|
"""Test the EnergyZero Service."""
|
|
data = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} | service_data
|
|
|
|
assert snapshot == await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
data,
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "incl_vat", "expected_price_type"),
|
|
[
|
|
(GAS_SERVICE_NAME, True, PriceType.MARKET_WITH_VAT),
|
|
(GAS_SERVICE_NAME, False, PriceType.MARKET),
|
|
(ENERGY_SERVICE_NAME, True, PriceType.MARKET_WITH_VAT),
|
|
(ENERGY_SERVICE_NAME, False, PriceType.MARKET),
|
|
],
|
|
)
|
|
async def test_service_price_type_mapping(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
incl_vat: bool,
|
|
expected_price_type: PriceType,
|
|
) -> None:
|
|
"""Test incl_vat maps to the expected EnergyZero price type."""
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": incl_vat,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert method.await_args.kwargs["price_type"] is expected_price_type
|
|
assert method.await_args.kwargs["local_tz"] == ZoneInfo(hass.config.time_zone)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
async def test_service_dates_normalized_to_hass_timezone(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
) -> None:
|
|
"""Test service input datetimes are normalized to the HA timezone."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
"start": "2023-01-01 23:30:00-01:00",
|
|
"end": "2023-01-02 00:30:00-01:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert method.await_args.kwargs["start_date"] == date(2023, 1, 2)
|
|
assert method.await_args.kwargs["local_tz"] == ZoneInfo("Europe/Amsterdam")
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "expected_prices"),
|
|
[
|
|
(
|
|
GAS_SERVICE_NAME,
|
|
[
|
|
{
|
|
"price": 0.45193447944,
|
|
"timestamp": "2026-04-10 04:00:00+00:00",
|
|
"start": "2026-04-10 04:00:00+00:00",
|
|
"end": "2026-04-11 04:00:00+00:00",
|
|
}
|
|
],
|
|
),
|
|
(
|
|
ENERGY_SERVICE_NAME,
|
|
[
|
|
{
|
|
"price": 0.12572,
|
|
"timestamp": "2026-04-10 21:00:00+00:00",
|
|
"start": "2026-04-10 21:00:00+00:00",
|
|
"end": "2026-04-10 22:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.125925,
|
|
"timestamp": "2026-04-10 22:00:00+00:00",
|
|
"start": "2026-04-10 22:00:00+00:00",
|
|
"end": "2026-04-10 23:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.1120525,
|
|
"timestamp": "2026-04-10 23:00:00+00:00",
|
|
"start": "2026-04-10 23:00:00+00:00",
|
|
"end": "2026-04-11 00:00:00+00:00",
|
|
},
|
|
],
|
|
),
|
|
],
|
|
)
|
|
async def test_service_filters_datetime_range(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
expected_prices: list[dict[str, str | float]],
|
|
) -> None:
|
|
"""Test services request each day and filter to the datetime range."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
mock_energyzero.reset_mock()
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": "2026-04-10 23:00:00+02:00",
|
|
"end": "2026-04-11 02:00:00+02:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert response == {"prices": expected_prices}
|
|
assert response["prices"]
|
|
assert all(item["timestamp"] == item["start"] for item in response["prices"])
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert [item.kwargs["start_date"] for item in method.await_args_list] == [
|
|
date(2026, 4, 10),
|
|
date(2026, 4, 11),
|
|
]
|
|
assert all(
|
|
item.kwargs["end_date"] == item.kwargs["start_date"]
|
|
for item in method.await_args_list
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
"equal_time", ["00:00:00", "14:30:00"], ids=["midnight", "non-midnight"]
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("service", "expected_count", "expected_first", "expected_last"),
|
|
[
|
|
(
|
|
GAS_SERVICE_NAME,
|
|
1,
|
|
{
|
|
"price": 0.430304930424,
|
|
"timestamp": "2026-04-11 04:00:00+00:00",
|
|
"start": "2026-04-11 04:00:00+00:00",
|
|
"end": "2026-04-12 04:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.430304930424,
|
|
"timestamp": "2026-04-11 04:00:00+00:00",
|
|
"start": "2026-04-11 04:00:00+00:00",
|
|
"end": "2026-04-12 04:00:00+00:00",
|
|
},
|
|
),
|
|
(
|
|
ENERGY_SERVICE_NAME,
|
|
24,
|
|
{
|
|
"price": 0.125925,
|
|
"timestamp": "2026-04-10 22:00:00+00:00",
|
|
"start": "2026-04-10 22:00:00+00:00",
|
|
"end": "2026-04-10 23:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.082385,
|
|
"timestamp": "2026-04-11 21:00:00+00:00",
|
|
"start": "2026-04-11 21:00:00+00:00",
|
|
"end": "2026-04-11 22:00:00+00:00",
|
|
},
|
|
),
|
|
],
|
|
)
|
|
async def test_service_equal_start_end_selects_full_local_day(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
equal_time: str,
|
|
expected_count: int,
|
|
expected_first: dict[str, str | float],
|
|
expected_last: dict[str, str | float],
|
|
) -> None:
|
|
"""Equal start/end datetimes select the full local calendar day."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
mock_energyzero.reset_mock()
|
|
equal_datetime = f"2026-04-11 {equal_time}"
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": equal_datetime,
|
|
"end": equal_datetime,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert len(response["prices"]) == expected_count
|
|
assert response["prices"][0] == expected_first
|
|
assert response["prices"][-1] == expected_last
|
|
assert all(item["timestamp"] == item["start"] for item in response["prices"])
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert len(method.await_args_list) == 1
|
|
assert method.await_args.kwargs["start_date"] == date(2026, 4, 11)
|
|
assert method.await_args.kwargs["end_date"] == date(2026, 4, 11)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
async def test_service_equal_start_end_full_day_respects_dst(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Equal start/end selects a local calendar day, not a fixed 24h window."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
|
|
in_range = TimeRange(
|
|
datetime(2026, 3, 28, 23, 0, tzinfo=UTC),
|
|
datetime(2026, 3, 29, 22, 0, tzinfo=UTC),
|
|
)
|
|
out_of_range = TimeRange(
|
|
datetime(2026, 3, 29, 22, 0, tzinfo=UTC),
|
|
datetime(2026, 3, 29, 23, 0, tzinfo=UTC),
|
|
)
|
|
mock_energyzero.get_electricity_prices.side_effect = None
|
|
mock_energyzero.get_electricity_prices.return_value = EnergyPrices(
|
|
prices={in_range: 0.1, out_of_range: 0.2}, average_price=None
|
|
)
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
ENERGY_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": "2026-03-29 00:00:00",
|
|
"end": "2026-03-29 00:00:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert response == {
|
|
"prices": [
|
|
{
|
|
"price": 0.1,
|
|
"timestamp": "2026-03-28 23:00:00+00:00",
|
|
"start": "2026-03-28 23:00:00+00:00",
|
|
"end": "2026-03-29 22:00:00+00:00",
|
|
}
|
|
]
|
|
}
|
|
assert mock_energyzero.get_electricity_prices.await_args.kwargs[
|
|
"start_date"
|
|
] == date(2026, 3, 29)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
async def test_service_date_and_equal_datetime_selects_full_local_day(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""A date-only start and a same-day midnight datetime end are equal."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
mock_energyzero.reset_mock()
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
GAS_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": "2026-04-11",
|
|
"end": "2026-04-11 00:00:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert response == {
|
|
"prices": [
|
|
{
|
|
"price": 0.430304930424,
|
|
"timestamp": "2026-04-11 04:00:00+00:00",
|
|
"start": "2026-04-11 04:00:00+00:00",
|
|
"end": "2026-04-12 04:00:00+00:00",
|
|
}
|
|
]
|
|
}
|
|
assert mock_energyzero.get_gas_prices.await_args.kwargs["start_date"] == date(
|
|
2026, 4, 11
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def config_entry_data(
|
|
mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest
|
|
) -> dict[str, str]:
|
|
"""Fixture for the config entry."""
|
|
if "config_entry" in request.param and request.param["config_entry"] is True:
|
|
return {"config_entry": mock_config_entry.entry_id}
|
|
|
|
return request.param
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
@pytest.mark.parametrize(
|
|
("config_entry_data", "service_data", "error", "error_message"),
|
|
[
|
|
({}, {}, vol.error.Error, "required key not provided .+"),
|
|
(
|
|
{"config_entry": True},
|
|
{},
|
|
vol.error.Error,
|
|
"required key not provided .+",
|
|
),
|
|
(
|
|
{},
|
|
{"incl_vat": True},
|
|
vol.error.Error,
|
|
"required key not provided .+",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{"incl_vat": "incorrect vat"},
|
|
vol.error.Error,
|
|
"expected bool at .+",
|
|
),
|
|
(
|
|
{"config_entry": "incorrect entry"},
|
|
{"incl_vat": True},
|
|
ServiceValidationError,
|
|
".+ config entry with ID incorrect entry was not found",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"start": "incorrect date",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date provided. Got incorrect date",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"end": "incorrect date",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date provided. Got incorrect date",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"start": "2023-01-02",
|
|
"end": "2023-01-01",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date range provided. End 2023-01-01 must be after start 2023-01-02",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"start": "2026-04-11 12:00:00",
|
|
"end": "2026-04-11 08:00:00",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date range provided. End 2026-04-11 08:00:00 must be "
|
|
"after start 2026-04-11 12:00:00",
|
|
),
|
|
],
|
|
indirect=["config_entry_data"],
|
|
)
|
|
async def test_service_validation(
|
|
hass: HomeAssistant,
|
|
service: str,
|
|
config_entry_data: dict[str, str],
|
|
service_data: dict[str, str],
|
|
error: type[Exception],
|
|
error_message: str,
|
|
) -> None:
|
|
"""Test the EnergyZero Service validation."""
|
|
|
|
with pytest.raises(error) as exc:
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
config_entry_data | service_data,
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
assert re.match(error_message, str(exc.value))
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
async def test_service_called_with_unloaded_entry(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
) -> None:
|
|
"""Test service calls with unloaded config entry."""
|
|
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
|
|
|
data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True}
|
|
|
|
with pytest.raises(
|
|
ServiceValidationError,
|
|
match=f"{mock_config_entry.title} for integration energyzero is not loaded",
|
|
):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
data,
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
async def test_service_no_data_returns_validation_error(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
) -> None:
|
|
"""Test backend no-data errors are surfaced as service validation errors."""
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
method.side_effect = EnergyZeroNoDataError(
|
|
"not found: prices do not span the whole requested date"
|
|
)
|
|
|
|
with pytest.raises(
|
|
ServiceValidationError,
|
|
match=r"No price data available for 2026-04-10\.?",
|
|
):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|