"""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, )