Files

346 lines
10 KiB
Python

"""Tests for the services provided by the EnergyZero integration."""
from datetime import date
import re
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
from energyzero import EnergyZeroNoDataError, PriceType
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",
}
],
),
(
ENERGY_SERVICE_NAME,
[
{"price": 0.12572, "timestamp": "2026-04-10 21:00:00+00:00"},
{"price": 0.125925, "timestamp": "2026-04-10 22:00:00+00:00"},
{"price": 0.1120525, "timestamp": "2026-04-10 23: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}
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.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",
),
],
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,
)