Add Fuelprices.dk (#163932)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Malene Trab
2026-07-10 20:59:42 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent 684b3e56ed
commit fab4809201
18 changed files with 1752 additions and 0 deletions
Generated
+2
View File
@@ -607,6 +607,8 @@ CLAUDE.md @home-assistant/core
/tests/components/frontend/ @home-assistant/frontend
/homeassistant/components/frontier_silicon/ @wlcrs
/tests/components/frontier_silicon/ @wlcrs
/homeassistant/components/fuelprices_dk/ @MTrab
/tests/components/fuelprices_dk/ @MTrab
/homeassistant/components/fujitsu_fglair/ @crevetor
/tests/components/fujitsu_fglair/ @crevetor
/homeassistant/components/fully_kiosk/ @cgarwood
@@ -0,0 +1,53 @@
"""Initialize the Fuelprices.dk component."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from .const import CONF_COMPANY, CONF_STATION, SUBENTRY_TYPE_STATION
from .coordinator import FuelPricesDKCoordinator
PLATFORMS = [Platform.SENSOR]
type FuelpricesDkConfigEntry = ConfigEntry[dict[str, FuelPricesDKCoordinator]]
async def async_setup_entry(
hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry
) -> bool:
"""Set up Fuelprices.dk from a config entry."""
config_entry.async_on_unload(config_entry.add_update_listener(_update_listener))
api_key = config_entry.data[CONF_API_KEY]
runtime_data: dict[str, FuelPricesDKCoordinator] = {}
for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_STATION):
subentry_id = subentry.subentry_id
company = subentry.data[CONF_COMPANY]
station = subentry.data[CONF_STATION]
coordinator = FuelPricesDKCoordinator(
hass,
api_key,
company,
station,
subentry_id,
config_entry,
)
runtime_data[subentry_id] = coordinator
await coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = runtime_data
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def _update_listener(hass: HomeAssistant, entry: FuelpricesDkConfigEntry) -> None:
"""Handle options or subentry updates by reloading the entry."""
hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@@ -0,0 +1,369 @@
"""Config flow for the Fuelprices.dk integration."""
from collections.abc import Mapping
from typing import Any, override
from aiohttp import ClientResponseError
from pybraendstofpriser import Braendstofpriser
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import callback
from .const import CONF_COMPANY, CONF_STATION, DOMAIN, WEBSITE_URL
def _get_api_error_key(exc: ClientResponseError) -> str:
"""Map API errors to config flow errors."""
if exc.status == 401:
return "invalid_api_key"
if exc.status == 429:
return "rate_limit_exceeded"
return "cannot_connect"
class FuelpricesDkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fuelprices.dk."""
VERSION = 1
@classmethod
@callback
@override
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"station": FuelpricesDkStationSubentryFlow}
def __init__(self) -> None:
"""Initialize the config flow."""
self.api: Braendstofpriser
self.companies: list[dict[str, Any]] = []
self.stations: Any = {}
self.company_name = ""
self.user_input: dict[str, Any] = {}
async def _async_validate_api_key(
self, api_key: str
) -> tuple[Braendstofpriser | None, list[dict[str, Any]], str | None]:
"""Validate the API key and fetch available companies."""
api = Braendstofpriser(api_key)
try:
companies = await api.list_companies()
except ClientResponseError as exc:
return None, [], _get_api_error_key(exc)
if not companies:
return None, [], "cannot_connect"
return api, companies, None
async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]:
"""Fetch stations for a company."""
try:
stations = await self.api.list_stations(company_name=company_name)
except ClientResponseError as exc:
return None, _get_api_error_key(exc)
if not stations:
return None, "cannot_connect"
return stations, None
def _show_company_selection_form(self, errors: dict[str, str]) -> ConfigFlowResult:
"""Show the company selection form."""
return self.async_show_form(
step_id="company_selection",
data_schema=vol.Schema(
{
vol.Required(CONF_COMPANY, default=self.company_name): vol.In(
[c["company"] for c in self.companies]
),
}
),
errors=errors,
)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - Enter API key."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
api, companies, error = await self._async_validate_api_key(
user_input[CONF_API_KEY]
)
if error is None:
assert api is not None
self.api = api
self.companies = companies
self.user_input = dict(user_input)
return await self.async_step_company_selection()
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
description_placeholders={"website_url": WEBSITE_URL},
)
async def async_step_company_selection(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the company selection step."""
if user_input is not None:
self.company_name = user_input[CONF_COMPANY]
self.user_input.update(user_input)
self.stations = {}
return await self.async_step_station_selection()
return self._show_company_selection_form({})
async def async_step_station_selection(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the station selection step."""
if not self.stations:
stations, error = await self._async_fetch_stations(self.company_name)
if error is not None:
return self._show_company_selection_form({"base": error})
self.stations = stations
if user_input is not None:
user_input[CONF_STATION] = self.stations.find(
"name", user_input[CONF_STATION]
)
# Create the main config entry with the first station subentry
self.user_input.update(user_input)
unique_id = (
f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}"
)
title = (
f"{self.user_input[CONF_COMPANY]} - "
f"{self.user_input[CONF_STATION]['name']}"
)
return self.async_create_entry(
title="Fuelprices.dk",
data={CONF_API_KEY: self.user_input[CONF_API_KEY]},
subentries=[
{
"subentry_type": "station",
"data": {
CONF_COMPANY: self.user_input[CONF_COMPANY],
CONF_STATION: self.user_input[CONF_STATION],
},
"title": title,
"unique_id": unique_id,
}
],
)
stations = [s["name"] for s in self.stations]
return self.async_show_form(
step_id="station_selection",
data_schema=vol.Schema(
{
vol.Required(CONF_STATION): vol.In(stations),
}
),
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow when API key is invalid/expired."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a new API key."""
errors: dict[str, str] = {}
if user_input is not None:
api = Braendstofpriser(user_input[CONF_API_KEY])
try:
await api.list_companies()
except ClientResponseError as exc:
errors["base"] = _get_api_error_key(exc)
if not errors:
entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
if entry is not None:
self.hass.config_entries.async_update_entry(
entry,
data={CONF_API_KEY: user_input[CONF_API_KEY]},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
class FuelpricesDkStationSubentryFlow(ConfigSubentryFlow):
"""Handle station subentries for Fuelprices.dk."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.api: Braendstofpriser
self.companies: list[dict[str, Any]] = []
self.stations: Any = {}
self.company_name = ""
self._errors: dict[str, str] = {}
self.user_input: dict[str, Any] = {}
async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]:
"""Fetch stations for a company."""
try:
stations = await self.api.list_stations(company_name=company_name)
except ClientResponseError as exc:
return None, _get_api_error_key(exc)
if not stations:
return None, "cannot_connect"
return stations, None
def _show_company_selection_form(
self, errors: dict[str, str] | None = None
) -> SubentryFlowResult:
"""Show the company selection form."""
default_company = self.user_input.get(CONF_COMPANY)
company_field = (
vol.Required(CONF_COMPANY, default=default_company)
if default_company
else vol.Required(CONF_COMPANY)
)
return self.async_show_form(
step_id="company_selection",
data_schema=vol.Schema(
{
company_field: vol.In([c["company"] for c in self.companies]),
}
),
errors=errors or {},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the initial step for adding a station subentry."""
await self._async_init_api()
return await self.async_step_company_selection(user_input)
async def _async_init_api(self) -> None:
"""Initialize API client and fetch companies."""
entry = self._get_entry()
api_key = entry.data[CONF_API_KEY]
self.api = Braendstofpriser(api_key)
try:
self.companies = await self.api.list_companies()
except ClientResponseError as exc:
self._errors["base"] = _get_api_error_key(exc)
return
if not self.companies:
self._errors["base"] = "cannot_connect"
async def async_step_company_selection(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the company selection step."""
if self._errors:
return self.async_abort(reason=self._errors["base"])
if user_input is not None:
self.company_name = user_input[CONF_COMPANY]
self.user_input.update(user_input)
self.stations = {}
return await self.async_step_station_selection()
return self._show_company_selection_form()
async def async_step_station_selection(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the station selection step."""
if not self.stations:
stations, error = await self._async_fetch_stations(self.company_name)
if error is not None:
self.user_input[CONF_COMPANY] = self.company_name
return self._show_company_selection_form({"base": error})
self.stations = stations
if user_input is not None:
user_input[CONF_STATION] = self.stations.find(
"name", user_input[CONF_STATION]
)
# Set UniqueID and abort if already existing
unique_id = (
f"{self.user_input[CONF_COMPANY]}_{user_input[CONF_STATION]['id']}"
)
entry = self._get_entry()
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="station_already_configured")
# Process the user input and show next selection form
self.user_input.update(user_input)
return await self._async_create_or_update_subentry()
stations = [s["name"] for s in self.stations]
return self.async_show_form(
step_id="station_selection",
data_schema=vol.Schema(
{
vol.Required(CONF_STATION): vol.In(stations),
}
),
errors=self._errors,
)
async def _async_create_or_update_subentry(self) -> SubentryFlowResult:
"""Create the station subentry."""
subentry_data = {
CONF_COMPANY: self.user_input[CONF_COMPANY],
CONF_STATION: self.user_input[CONF_STATION],
}
unique_id = (
f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}"
)
title = (
f"{self.user_input[CONF_COMPANY]} - {self.user_input[CONF_STATION]['name']}"
)
entry = self._get_entry()
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_create_entry(
title=title,
data=subentry_data,
unique_id=unique_id,
)
@@ -0,0 +1,10 @@
"""Constants for the Fuelprices.dk integration."""
DOMAIN = "fuelprices_dk"
CONF_COMPANY = "company"
CONF_STATION = "station"
SUBENTRY_TYPE_STATION = "station"
WEBSITE_URL = "https://fuelprices.dk"
@@ -0,0 +1,64 @@
"""Coordinator for the Fuelprices.dk integration."""
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any, override
from aiohttp import ClientResponseError
from pybraendstofpriser import Braendstofpriser
from pybraendstofpriser.exceptions import ProductNotFoundError
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
if TYPE_CHECKING:
from . import FuelpricesDkConfigEntry
SCAN_INTERVAL = timedelta(hours=1)
_LOGGER = logging.getLogger(__name__)
class FuelPricesDKCoordinator(DataUpdateCoordinator[dict[str, float | None]]):
"""Data update coordinator for the Fuelprices.dk integration."""
def __init__(
self,
hass: HomeAssistant,
api_key: str,
company: str,
station: dict[str, Any],
subentry_id: str,
config_entry: FuelpricesDkConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
name=company,
logger=_LOGGER,
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self._api = Braendstofpriser(api_key)
self.company = company
self.station_id: int = station["id"]
self.station_name: str = station["name"]
self.subentry_id = subentry_id
@override
async def _async_update_data(self) -> dict[str, float | None]:
"""Handle data update request from the coordinator."""
try:
data = await self._api.get_prices(self.station_id)
except ProductNotFoundError as exc:
raise ConfigEntryError(exc) from exc
except ClientResponseError as exc:
if exc.status == 401:
raise ConfigEntryAuthFailed(exc) from exc
raise ConfigEntryError(exc) from exc
self.station_name = data["station"]["name"]
return dict(data["prices"])
@@ -0,0 +1,11 @@
{
"domain": "fuelprices_dk",
"name": "Fuelprices.dk",
"codeowners": ["@MTrab"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fuelprices_dk",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pybraendstofpriser==2.2.0"]
}
@@ -0,0 +1,86 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any additional actions.
docs-conditions:
status: exempt
comment: |
The integration does not provide any additional conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: |
The integration does not provide any additional triggers.
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
The integration does not provide any additional actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration cannot be discovered, it connects to a cloud service.
discovery:
status: exempt
comment: |
This integration cannot be discovered, it connects to a cloud service.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations:
status: exempt
comment: |
The integration does not provide any additional icons.
reconfiguration-flow: todo
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,103 @@
"""Sensor platform for the Fuelprices.dk integration."""
from typing import TYPE_CHECKING, override
from homeassistant.components.sensor import (
RestoreSensor,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify as util_slugify
from .const import DOMAIN
from .coordinator import FuelPricesDKCoordinator
if TYPE_CHECKING:
from . import FuelpricesDkConfigEntry
SENSORS = [
SensorEntityDescription(
key="price",
name="Fuel Price",
native_unit_of_measurement="DKK/L",
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:gas-station",
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: FuelpricesDkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform for Fuelprices.dk."""
for coordinator in entry.runtime_data.values():
async_add_entities(
(
FuelpricesDkSensor(
coordinator,
coordinator.station_name,
product_key,
sensor,
)
for sensor in SENSORS
for product_key in coordinator.data
),
config_subentry_id=coordinator.subentry_id,
)
class FuelpricesDkSensor(CoordinatorEntity[FuelPricesDKCoordinator], RestoreSensor):
"""Sensor for Fuelprices.dk."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FuelPricesDKCoordinator,
station_name: str,
product_key: str,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._product_key = product_key
self._station_name = station_name
self._attr_name = product_key
self._attr_unique_id = util_slugify(
f"{self.coordinator.station_id}_{self.entity_description.key}_{product_key}"
)
self._attr_config_subentry_id = self.coordinator.subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(self.coordinator.station_id))},
entry_type=DeviceEntryType.SERVICE,
name=self._station_name,
manufacturer=self.coordinator.company,
model=self.coordinator.station_name,
)
@property
@override
def available(self) -> bool:
"""Return whether the entity is available."""
return super().available and self._product_key in self.coordinator.data
@property
@override
def native_value(self) -> float | None:
"""Return the current value of the sensor."""
price = self.coordinator.data[self._product_key]
if isinstance(price, int | float):
return float(price)
return None
@@ -0,0 +1,94 @@
{
"config": {
"abort": {
"already_configured": "This API key is already configured.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "Invalid API key provided",
"rate_limit_exceeded": "Too many requests to the API. Please try again later.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"station_already_configured": "The selected station for this company is already configured"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]",
"rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]"
},
"step": {
"company_selection": {
"data": {
"company": "Select company"
},
"data_description": {
"company": "The company you want to fetch prices from"
},
"description": "Select company"
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your personal API key"
},
"description": "Your API key needs to be updated."
},
"station_selection": {
"data": {
"station": "Select station"
},
"data_description": {
"station": "The station you want to create sensors for"
},
"description": "Select station to show prices for"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your personal API key"
},
"description": "Enter your Fuelprices.dk API key\nIf you do not have an API key, you can get one for free at {website_url}"
}
}
},
"config_subentries": {
"station": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]",
"rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]",
"station_already_configured": "[%key:component::fuelprices_dk::config::abort::station_already_configured%]"
},
"entry_type": "Station",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]",
"rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]"
},
"initiate_flow": {
"user": "Add station"
},
"step": {
"company_selection": {
"data": {
"company": "[%key:component::fuelprices_dk::config::step::company_selection::data::company%]"
},
"data_description": {
"company": "[%key:component::fuelprices_dk::config::step::company_selection::data_description::company%]"
},
"description": "[%key:component::fuelprices_dk::config::step::company_selection::description%]"
},
"station_selection": {
"data": {
"station": "[%key:component::fuelprices_dk::config::step::station_selection::data::station%]"
},
"data_description": {
"station": "[%key:component::fuelprices_dk::config::step::station_selection::data_description::station%]"
},
"description": "[%key:component::fuelprices_dk::config::step::station_selection::description%]"
}
}
}
}
}
+1
View File
@@ -257,6 +257,7 @@ FLOWS = {
"fritzbox_callmonitor",
"fronius",
"frontier_silicon",
"fuelprices_dk",
"fujitsu_fglair",
"fully_kiosk",
"fumis",
@@ -2319,6 +2319,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"fuelprices_dk": {
"name": "Fuelprices.dk",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"fujitsu": {
"name": "Fujitsu",
"integrations": {
+3
View File
@@ -2063,6 +2063,9 @@ pyblu==2.0.8
# homeassistant.components.neato
pybotvac==0.0.29
# homeassistant.components.fuelprices_dk
pybraendstofpriser==2.2.0
# homeassistant.components.braviatv
pybravia==0.4.1
@@ -0,0 +1,12 @@
"""Tests for the Danish Fuelprices integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the integration from a mock config entry."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -0,0 +1,81 @@
"""Common fixtures for Fuelprices.dk tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from pybraendstofpriser import Flist
import pytest
from homeassistant.components.fuelprices_dk.const import (
CONF_COMPANY,
CONF_STATION,
DOMAIN,
)
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_API_KEY
from tests.common import MockConfigEntry
TEST_API_KEY = "test-api-key"
TEST_COMPANY = "Circle K"
TEST_STATION = {"id": 1234, "name": "Aarhus C"}
TEST_PRICES = {"Blyfri95": 14.29, "Diesel": 12.99, "Blyfri98": 14.99}
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry for config flow tests."""
with patch(
"homeassistant.components.fuelprices_dk.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Create a standard mock config entry with one station subentry."""
return MockConfigEntry(
domain=DOMAIN,
title="Fuelprices.dk",
version=1,
data={CONF_API_KEY: TEST_API_KEY},
subentries_data=[
ConfigSubentryData(
subentry_type="station",
title=f"{TEST_COMPANY} - {TEST_STATION['name']}",
unique_id=f"{TEST_COMPANY}_{TEST_STATION['id']}",
data={
CONF_COMPANY: TEST_COMPANY,
CONF_STATION: TEST_STATION,
},
)
],
)
@pytest.fixture
def mock_braendstofpriser() -> Generator[AsyncMock]:
"""Mock the pybraendstofpriser client used by the integration."""
with (
patch(
"homeassistant.components.fuelprices_dk.config_flow.Braendstofpriser",
autospec=True,
) as mock_config_flow_client,
patch(
"homeassistant.components.fuelprices_dk.coordinator.Braendstofpriser",
new=mock_config_flow_client,
),
):
client = mock_config_flow_client.return_value
client.list_companies.return_value = [{"company": TEST_COMPANY}]
client.list_stations.return_value = Flist([TEST_STATION])
client.get_prices.return_value = {
"station": {
"id": TEST_STATION["id"],
"name": TEST_STATION["name"],
"last_update": "2024-01-01T12:00:00",
},
"prices": TEST_PRICES,
}
yield client
@@ -0,0 +1,166 @@
# serializer version: 1
# name: test_sensors[sensor.aarhus_c_blyfri95-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.aarhus_c_blyfri95',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Blyfri95',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:gas-station',
'original_name': 'Blyfri95',
'platform': 'fuelprices_dk',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '1234_price_blyfri95',
'unit_of_measurement': 'DKK/L',
})
# ---
# name: test_sensors[sensor.aarhus_c_blyfri95-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Aarhus C Blyfri95',
<EntityStateAttribute.ICON: 'icon'>: 'mdi:gas-station',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'DKK/L',
}),
'context': <ANY>,
'entity_id': 'sensor.aarhus_c_blyfri95',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '14.29',
})
# ---
# name: test_sensors[sensor.aarhus_c_blyfri98-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.aarhus_c_blyfri98',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Blyfri98',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:gas-station',
'original_name': 'Blyfri98',
'platform': 'fuelprices_dk',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '1234_price_blyfri98',
'unit_of_measurement': 'DKK/L',
})
# ---
# name: test_sensors[sensor.aarhus_c_blyfri98-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Aarhus C Blyfri98',
<EntityStateAttribute.ICON: 'icon'>: 'mdi:gas-station',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'DKK/L',
}),
'context': <ANY>,
'entity_id': 'sensor.aarhus_c_blyfri98',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '14.99',
})
# ---
# name: test_sensors[sensor.aarhus_c_diesel-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.aarhus_c_diesel',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Diesel',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:gas-station',
'original_name': 'Diesel',
'platform': 'fuelprices_dk',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '1234_price_diesel',
'unit_of_measurement': 'DKK/L',
})
# ---
# name: test_sensors[sensor.aarhus_c_diesel-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Aarhus C Diesel',
<EntityStateAttribute.ICON: 'icon'>: 'mdi:gas-station',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'DKK/L',
}),
'context': <ANY>,
'entity_id': 'sensor.aarhus_c_diesel',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.99',
})
# ---
@@ -0,0 +1,447 @@
"""Test the Fuelprices.dk config flow."""
from collections.abc import Callable
from unittest.mock import AsyncMock, Mock
from aiohttp import ClientResponseError
from pybraendstofpriser import Flist
import pytest
from homeassistant.components.fuelprices_dk.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from .conftest import TEST_API_KEY, TEST_COMPANY, TEST_STATION
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
def _client_error(status: int) -> ClientResponseError:
"""Create an aiohttp client response error with a specific status code."""
return ClientResponseError(
request_info=Mock(),
history=(),
status=status,
message="error",
headers=None,
)
async def test_full_user_flow(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test a full successful config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "station_selection"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"station": TEST_STATION["name"]}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Fuelprices.dk"
assert result["data"] == {CONF_API_KEY: TEST_API_KEY}
assert len(result["subentries"]) == 1
subentry = result["subentries"][0]
assert subentry["subentry_type"] == "station"
assert subentry["title"] == f"{TEST_COMPANY} - {TEST_STATION['name']}"
assert subentry["unique_id"] == f"{TEST_COMPANY}_{TEST_STATION['id']}"
assert subentry["data"] == {"company": TEST_COMPANY, "station": TEST_STATION}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("status", "error"),
[
(401, "invalid_api_key"),
(429, "rate_limit_exceeded"),
(500, "cannot_connect"),
],
)
async def test_user_flow_recovers_from_api_errors(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
status: int,
error: str,
) -> None:
"""Test the user flow shows an error and then recovers."""
mock_braendstofpriser.list_companies.side_effect = _client_error(status)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": error}
mock_braendstofpriser.list_companies.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"station": TEST_STATION["name"]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_recovers_without_companies(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
) -> None:
"""Test the user flow recovers when the API returns no companies."""
mock_braendstofpriser.list_companies.return_value = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
mock_braendstofpriser.list_companies.return_value = [{"company": TEST_COMPANY}]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"station": TEST_STATION["name"]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_duplicate_api_key_aborts(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test flow aborts when the same API key is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"configure_stations",
[
lambda mock: setattr(mock.list_stations, "side_effect", _client_error(500)),
lambda mock: setattr(mock.list_stations, "return_value", Flist([])),
],
ids=["error", "empty"],
)
async def test_user_flow_station_error_returns_to_company_selection(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
configure_stations: Callable[[AsyncMock], None],
) -> None:
"""Test station loading errors return the user to company selection."""
configure_stations(mock_braendstofpriser)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: TEST_API_KEY}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
assert result["errors"] == {"base": "cannot_connect"}
mock_braendstofpriser.list_stations.side_effect = None
mock_braendstofpriser.list_stations.return_value = Flist([TEST_STATION])
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"station": TEST_STATION["name"]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_allows_different_api_key(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_braendstofpriser: AsyncMock,
) -> None:
"""Test flow allows a different API key."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "other-api-key"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"station": TEST_STATION["name"]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_API_KEY: "other-api-key"}
async def test_reauth_success(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_braendstofpriser: AsyncMock,
) -> None:
"""Test reauthentication updates the API key."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "new-api-key"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new-api-key"
@pytest.mark.parametrize(
("status", "error"),
[
(401, "invalid_api_key"),
(429, "rate_limit_exceeded"),
(500, "cannot_connect"),
],
)
async def test_reauth_recovers_from_api_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_braendstofpriser: AsyncMock,
status: int,
error: str,
) -> None:
"""Test reauth shows an error and then recovers."""
mock_config_entry.add_to_hass(hass)
mock_braendstofpriser.list_companies.side_effect = _client_error(status)
result = await mock_config_entry.start_reauth_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "bad-key"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": error}
mock_braendstofpriser.list_companies.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "new-api-key"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_subentry_flow_create(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating a station subentry."""
await setup_integration(hass, mock_config_entry)
new_station = {"id": 4321, "name": "Aarhus N"}
mock_braendstofpriser.list_stations.return_value = Flist(
[TEST_STATION, new_station]
)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "station"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "station_selection"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"station": new_station["name"]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{TEST_COMPANY} - {new_station['name']}"
assert result["unique_id"] == f"{TEST_COMPANY}_{new_station['id']}"
async def test_subentry_flow_duplicate_station(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test subentry flow aborts for an already configured station."""
await setup_integration(hass, mock_config_entry)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "station"),
context={"source": SOURCE_USER},
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"station": TEST_STATION["name"]}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "station_already_configured"
@pytest.mark.parametrize(
("status", "reason"),
[
(401, "invalid_api_key"),
(429, "rate_limit_exceeded"),
(500, "cannot_connect"),
],
)
async def test_subentry_flow_api_init_error(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
status: int,
reason: str,
) -> None:
"""Test subentry flow aborts for API init errors."""
await setup_integration(hass, mock_config_entry)
mock_braendstofpriser.list_companies.side_effect = _client_error(status)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "station"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_subentry_flow_no_companies_aborts(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test subentry flow aborts when no companies are returned."""
await setup_integration(hass, mock_config_entry)
mock_braendstofpriser.list_companies.return_value = []
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "station"),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.parametrize(
"configure_stations",
[
lambda mock: setattr(mock.list_stations, "side_effect", _client_error(500)),
lambda mock: setattr(mock.list_stations, "return_value", Flist([])),
],
ids=["error", "empty"],
)
async def test_subentry_flow_station_error_returns_to_company_selection(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
configure_stations: Callable[[AsyncMock], None],
) -> None:
"""Test station loading errors return the user to company selection."""
await setup_integration(hass, mock_config_entry)
configure_stations(mock_braendstofpriser)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "station"),
context={"source": SOURCE_USER},
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "company_selection"
assert result["errors"] == {"base": "cannot_connect"}
new_station = {"id": 4321, "name": "Aarhus N"}
mock_braendstofpriser.list_stations.side_effect = None
mock_braendstofpriser.list_stations.return_value = Flist(
[TEST_STATION, new_station]
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"company": TEST_COMPANY}
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {"station": new_station["name"]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
+147
View File
@@ -0,0 +1,147 @@
"""Test initialization for Fuelprices.dk."""
from unittest.mock import AsyncMock, Mock
from aiohttp import ClientResponseError
from pybraendstofpriser import Flist
from pybraendstofpriser.exceptions import ProductNotFoundError
import pytest
from homeassistant.config_entries import (
ConfigEntryState,
ConfigSubentry,
ConfigSubentryData,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import TEST_API_KEY, TEST_COMPANY, TEST_PRICES, TEST_STATION
from tests.common import MockConfigEntry
def _client_error(status: int) -> ClientResponseError:
"""Create an aiohttp client response error with a specific status code."""
return ClientResponseError(
request_info=Mock(),
history=(),
status=status,
message="error",
headers=None,
)
async def test_setup_and_unload_entry(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the config entry is set up and unloaded correctly."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_reload_on_subentry_added(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the entry reloads and adds entities when a subentry is added."""
await setup_integration(hass, mock_config_entry)
entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert len(entities) == len(TEST_PRICES)
mock_braendstofpriser.get_prices.reset_mock()
new_station = {"id": 4321, "name": "Aarhus N"}
hass.config_entries.async_add_subentry(
mock_config_entry,
ConfigSubentry(
subentry_type="station",
title=f"{TEST_COMPANY} - {new_station['name']}",
unique_id=f"{TEST_COMPANY}_{new_station['id']}",
data={"company": TEST_COMPANY, "station": new_station},
),
)
await hass.async_block_till_done()
entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert len(entities) == len(TEST_PRICES) * 2
assert mock_braendstofpriser.get_prices.await_count == 2
async def test_skips_non_station_subentries(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup skips unsupported subentry types."""
config_entry = MockConfigEntry(
domain="fuelprices_dk",
version=1,
data={CONF_API_KEY: TEST_API_KEY},
subentries_data=[
ConfigSubentryData(
subentry_type="other",
title="Other",
unique_id="other_1",
data={"company": TEST_COMPANY, "station": TEST_STATION},
)
],
)
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
mock_braendstofpriser.get_prices.assert_not_called()
async def test_stations_use_flist(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup completes when the API returns an Flist of stations."""
mock_braendstofpriser.list_stations.return_value = Flist([TEST_STATION])
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
(_client_error(401), ConfigEntryState.SETUP_ERROR),
(_client_error(500), ConfigEntryState.SETUP_ERROR),
(ProductNotFoundError("missing"), ConfigEntryState.SETUP_ERROR),
],
ids=["auth_failed", "cannot_connect", "product_not_found"],
)
async def test_setup_error_handling(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup handles API errors during the first refresh."""
mock_braendstofpriser.get_prices.side_effect = side_effect
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
@@ -0,0 +1,97 @@
"""Test sensor platform for Fuelprices.dk."""
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import TEST_PRICES
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_sensors(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the sensor entities."""
with patch("homeassistant.components.fuelprices_dk.PLATFORMS", ["sensor"]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_updates(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the sensor updates when the coordinator refreshes."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.aarhus_c_blyfri95").state == "14.29"
mock_braendstofpriser.get_prices.return_value = {
"station": {"id": 1234, "name": "Aarhus C", "last_update": None},
"prices": {**TEST_PRICES, "Blyfri95": 15.99},
}
freezer.tick(3600)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.aarhus_c_blyfri95").state == "15.99"
async def test_sensor_becomes_unavailable_when_product_missing(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a sensor becomes unavailable when its product is not returned."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.aarhus_c_blyfri95").state == "14.29"
remaining = {k: v for k, v in TEST_PRICES.items() if k != "Blyfri95"}
mock_braendstofpriser.get_prices.return_value = {
"station": {"id": 1234, "name": "Aarhus C", "last_update": None},
"prices": remaining,
}
freezer.tick(3600)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.aarhus_c_blyfri95").state == STATE_UNAVAILABLE
async def test_sensor_ignores_non_numeric_price(
hass: HomeAssistant,
mock_braendstofpriser: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a sensor reports unknown when the API returns a non-numeric price."""
await setup_integration(hass, mock_config_entry)
mock_braendstofpriser.get_prices.return_value = {
"station": {"id": 1234, "name": "Aarhus C", "last_update": None},
"prices": {**TEST_PRICES, "Blyfri95": "n/a"},
}
freezer.tick(3600)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.aarhus_c_blyfri95").state == STATE_UNKNOWN