From 36cc3682ca8459743a2177d31e744ae59e1d6e15 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 30 Sep 2025 23:34:33 +0200 Subject: [PATCH] Add Firefly III integration (#147062) Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/firefly_iii/__init__.py | 27 +++ .../components/firefly_iii/config_flow.py | 97 ++++++++ homeassistant/components/firefly_iii/const.py | 6 + .../components/firefly_iii/coordinator.py | 137 +++++++++++ .../components/firefly_iii/entity.py | 40 ++++ .../components/firefly_iii/icons.json | 18 ++ .../components/firefly_iii/manifest.json | 10 + .../components/firefly_iii/quality_scale.yaml | 68 ++++++ .../components/firefly_iii/sensor.py | 142 +++++++++++ .../components/firefly_iii/strings.json | 39 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/firefly_iii/__init__.py | 13 + tests/components/firefly_iii/conftest.py | 95 ++++++++ .../firefly_iii/fixtures/about.json | 7 + .../firefly_iii/fixtures/accounts.json | 178 ++++++++++++++ .../firefly_iii/fixtures/bills.json | 44 ++++ .../firefly_iii/fixtures/budgets.json | 35 +++ .../firefly_iii/fixtures/categories.json | 34 +++ .../firefly_iii/fixtures/category.json | 32 +++ .../fixtures/primary_currency.json | 15 ++ .../firefly_iii/snapshots/test_sensor.ambr | 225 ++++++++++++++++++ .../firefly_iii/test_config_flow.py | 134 +++++++++++ tests/components/firefly_iii/test_sensor.py | 31 +++ 29 files changed, 1453 insertions(+) create mode 100644 homeassistant/components/firefly_iii/__init__.py create mode 100644 homeassistant/components/firefly_iii/config_flow.py create mode 100644 homeassistant/components/firefly_iii/const.py create mode 100644 homeassistant/components/firefly_iii/coordinator.py create mode 100644 homeassistant/components/firefly_iii/entity.py create mode 100644 homeassistant/components/firefly_iii/icons.json create mode 100644 homeassistant/components/firefly_iii/manifest.json create mode 100644 homeassistant/components/firefly_iii/quality_scale.yaml create mode 100644 homeassistant/components/firefly_iii/sensor.py create mode 100644 homeassistant/components/firefly_iii/strings.json create mode 100644 tests/components/firefly_iii/__init__.py create mode 100644 tests/components/firefly_iii/conftest.py create mode 100644 tests/components/firefly_iii/fixtures/about.json create mode 100644 tests/components/firefly_iii/fixtures/accounts.json create mode 100644 tests/components/firefly_iii/fixtures/bills.json create mode 100644 tests/components/firefly_iii/fixtures/budgets.json create mode 100644 tests/components/firefly_iii/fixtures/categories.json create mode 100644 tests/components/firefly_iii/fixtures/category.json create mode 100644 tests/components/firefly_iii/fixtures/primary_currency.json create mode 100644 tests/components/firefly_iii/snapshots/test_sensor.ambr create mode 100644 tests/components/firefly_iii/test_config_flow.py create mode 100644 tests/components/firefly_iii/test_sensor.py diff --git a/.strict-typing b/.strict-typing index d483d04f702..cacab1a4151 100644 --- a/.strict-typing +++ b/.strict-typing @@ -203,6 +203,7 @@ homeassistant.components.feedreader.* homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* +homeassistant.components.firefly_iii.* homeassistant.components.fitbit.* homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* diff --git a/CODEOWNERS b/CODEOWNERS index 47ab063477a..5b1c185bbf7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -492,6 +492,8 @@ build.json @home-assistant/supervisor /tests/components/filesize/ @gjohansson-ST /homeassistant/components/filter/ @dgomes /tests/components/filter/ @dgomes +/homeassistant/components/firefly_iii/ @erwindouna +/tests/components/firefly_iii/ @erwindouna /homeassistant/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky /homeassistant/components/firmata/ @DaAwesomeP diff --git a/homeassistant/components/firefly_iii/__init__.py b/homeassistant/components/firefly_iii/__init__.py new file mode 100644 index 00000000000..6a778ae8c8a --- /dev/null +++ b/homeassistant/components/firefly_iii/__init__.py @@ -0,0 +1,27 @@ +"""The Firefly III integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool: + """Set up Firefly III from a config entry.""" + + coordinator = FireflyDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py new file mode 100644 index 00000000000..ceebaa914a9 --- /dev/null +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow for the Firefly III integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyfirefly import ( + Firefly, + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_API_KEY): str, + } +) + + +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: + """Validate the user input allows us to connect.""" + + try: + client = Firefly( + api_url=data[CONF_URL], + api_key=data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + await client.get_about() + except FireflyAuthenticationError: + raise InvalidAuth from None + except FireflyConnectionError as err: + raise CannotConnect from err + except FireflyTimeoutError as err: + raise FireflyClientTimeout from err + + return True + + +class FireflyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Firefly III.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await _validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except FireflyClientTimeout: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_URL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class FireflyClientTimeout(HomeAssistantError): + """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/firefly_iii/const.py b/homeassistant/components/firefly_iii/const.py new file mode 100644 index 00000000000..d8de96ddc5d --- /dev/null +++ b/homeassistant/components/firefly_iii/const.py @@ -0,0 +1,6 @@ +"""Constants for the Firefly III integration.""" + +DOMAIN = "firefly_iii" + +MANUFACTURER = "Firefly III" +NAME = "Firefly III" diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py new file mode 100644 index 00000000000..3b64b3197cd --- /dev/null +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -0,0 +1,137 @@ +"""Data Update Coordinator for Firefly III integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from aiohttp import CookieJar +from pyfirefly import ( + Firefly, + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +from pyfirefly.models import Account, Bill, Budget, Category, Currency + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator] + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) + + +@dataclass +class FireflyCoordinatorData: + """Data structure for Firefly III coordinator data.""" + + accounts: list[Account] + categories: list[Category] + category_details: list[Category] + budgets: list[Budget] + bills: list[Bill] + primary_currency: Currency + + +class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]): + """Coordinator to manage data updates for Firefly III integration.""" + + config_entry: FireflyConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.firefly = Firefly( + api_url=self.config_entry.data[CONF_URL], + api_key=self.config_entry.data[CONF_API_KEY], + session=async_create_clientsession( + self.hass, + self.config_entry.data[CONF_VERIFY_SSL], + cookie_jar=CookieJar(unsafe=True), + ), + ) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.firefly.get_about() + except FireflyAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyTimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + async def _async_update_data(self) -> FireflyCoordinatorData: + """Fetch data from Firefly III API.""" + now = datetime.now() + start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end_date = now + + try: + accounts = await self.firefly.get_accounts() + categories = await self.firefly.get_categories() + category_details = [ + await self.firefly.get_category( + category_id=int(category.id), start=start_date, end=end_date + ) + for category in categories + ] + primary_currency = await self.firefly.get_currency_primary() + budgets = await self.firefly.get_budgets() + bills = await self.firefly.get_bills() + except FireflyAuthenticationError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyTimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + return FireflyCoordinatorData( + accounts=accounts, + categories=categories, + category_details=category_details, + budgets=budgets, + bills=bills, + primary_currency=primary_currency, + ) diff --git a/homeassistant/components/firefly_iii/entity.py b/homeassistant/components/firefly_iii/entity.py new file mode 100644 index 00000000000..0281065a6e7 --- /dev/null +++ b/homeassistant/components/firefly_iii/entity.py @@ -0,0 +1,40 @@ +"""Base entity for Firefly III integration.""" + +from __future__ import annotations + +from yarl import URL + +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import FireflyDataUpdateCoordinator + + +class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]): + """Base class for Firefly III entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FireflyDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize a Firefly entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + configuration_url=URL(coordinator.config_entry.data[CONF_URL]), + identifiers={ + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{self.entity_description.key}", + ) + }, + ) diff --git a/homeassistant/components/firefly_iii/icons.json b/homeassistant/components/firefly_iii/icons.json new file mode 100644 index 00000000000..9a849804192 --- /dev/null +++ b/homeassistant/components/firefly_iii/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "account_type": { + "default": "mdi:bank", + "state": { + "expense": "mdi:cash-minus", + "revenue": "mdi:cash-plus", + "asset": "mdi:account-cash", + "liability": "mdi:hand-coin" + } + }, + "category": { + "default": "mdi:label" + } + } + } +} diff --git a/homeassistant/components/firefly_iii/manifest.json b/homeassistant/components/firefly_iii/manifest.json new file mode 100644 index 00000000000..18f9f794331 --- /dev/null +++ b/homeassistant/components/firefly_iii/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "firefly_iii", + "name": "Firefly III", + "codeowners": ["@erwindouna"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/firefly_iii", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["pyfirefly==0.1.5"] +} diff --git a/homeassistant/components/firefly_iii/quality_scale.yaml b/homeassistant/components/firefly_iii/quality_scale.yaml new file mode 100644 index 00000000000..a985e389588 --- /dev/null +++ b/homeassistant/components/firefly_iii/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + 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: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + No explicit parallel updates are defined. + reauthentication-flow: + status: todo + comment: | + No reauthentication flow is defined. It will be done in a next iteration. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/firefly_iii/sensor.py b/homeassistant/components/firefly_iii/sensor.py new file mode 100644 index 00000000000..f73238d7b2e --- /dev/null +++ b/homeassistant/components/firefly_iii/sensor.py @@ -0,0 +1,142 @@ +"""Sensor platform for Firefly III integration.""" + +from __future__ import annotations + +from pyfirefly.models import Account, Category + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator +from .entity import FireflyBaseEntity + +ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="account_type", + translation_key="account", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + ), +) + +CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="category", + translation_key="category", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FireflyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Firefly III sensor platform.""" + coordinator = entry.runtime_data + entities: list[SensorEntity] = [ + FireflyAccountEntity( + coordinator=coordinator, + entity_description=description, + account=account, + ) + for account in coordinator.data.accounts + for description in ACCOUNT_SENSORS + ] + + entities.extend( + FireflyCategoryEntity( + coordinator=coordinator, + entity_description=description, + category=category, + ) + for category in coordinator.data.category_details + for description in CATEGORY_SENSORS + ) + + async_add_entities(entities) + + +class FireflyAccountEntity(FireflyBaseEntity, SensorEntity): + """Entity for Firefly III account.""" + + def __init__( + self, + coordinator: FireflyDataUpdateCoordinator, + entity_description: SensorEntityDescription, + account: Account, + ) -> None: + """Initialize Firefly account entity.""" + super().__init__(coordinator, entity_description) + self._account = account + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}" + self._attr_name = account.attributes.name + self._attr_native_unit_of_measurement = ( + coordinator.data.primary_currency.attributes.code + ) + + # Account type state doesn't go well with the icons.json. Need to fix it. + if account.attributes.type == "expense": + self._attr_icon = "mdi:cash-minus" + elif account.attributes.type == "asset": + self._attr_icon = "mdi:account-cash" + elif account.attributes.type == "revenue": + self._attr_icon = "mdi:cash-plus" + elif account.attributes.type == "liability": + self._attr_icon = "mdi:hand-coin" + else: + self._attr_icon = "mdi:bank" + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + return self._account.attributes.current_balance + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return extra state attributes for the account entity.""" + return { + "account_role": self._account.attributes.account_role or "", + "account_type": self._account.attributes.type or "", + "current_balance": str(self._account.attributes.current_balance or ""), + } + + +class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity): + """Entity for Firefly III category.""" + + def __init__( + self, + coordinator: FireflyDataUpdateCoordinator, + entity_description: SensorEntityDescription, + category: Category, + ) -> None: + """Initialize Firefly category entity.""" + super().__init__(coordinator, entity_description) + self._category = category + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}" + self._attr_name = category.attributes.name + self._attr_native_unit_of_measurement = ( + coordinator.data.primary_currency.attributes.code + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + spent_items = self._category.attributes.spent or [] + earned_items = self._category.attributes.earned or [] + + spent = sum(float(item.sum) for item in spent_items if item.sum is not None) + earned = sum(float(item.sum) for item in earned_items if item.sum is not None) + + if spent == 0 and earned == 0: + return None + return spent + earned diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json new file mode 100644 index 00000000000..14fc692b7ba --- /dev/null +++ b/homeassistant/components/firefly_iii/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "The API key for authenticating with Firefly", + "verify_ssl": "Verify the SSL certificate of the Firefly instance" + }, + "description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while trying to connect to the Firefly instance: {error}" + }, + "invalid_auth": { + "message": "An error occurred while trying to authenticate: {error}" + }, + "timeout_connect": { + "message": "A timeout occurred while trying to connect to the Firefly instance: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f9e50f9a26c..1d2c6fc21a7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -201,6 +201,7 @@ FLOWS = { "fibaro", "file", "filesize", + "firefly_iii", "fireservicerota", "fitbit", "fivem", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b0ef2400f04..71c3ee23c81 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1984,6 +1984,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "firefly_iii": { + "name": "Firefly III", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "fireservicerota": { "name": "FireServiceRota", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index dcf71efe898..c05ec7019b2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1786,6 +1786,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.firefly_iii.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fitbit.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d66bbdfb116..8b5077eba58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2020,6 +2020,9 @@ pyfibaro==0.8.3 # homeassistant.components.fido pyfido==2.1.2 +# homeassistant.components.firefly_iii +pyfirefly==0.1.5 + # homeassistant.components.fireservicerota pyfireservicerota==0.0.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16397e62653..dfe52fcae5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1689,6 +1689,9 @@ pyfibaro==0.8.3 # homeassistant.components.fido pyfido==2.1.2 +# homeassistant.components.firefly_iii +pyfirefly==0.1.5 + # homeassistant.components.fireservicerota pyfireservicerota==0.0.46 diff --git a/tests/components/firefly_iii/__init__.py b/tests/components/firefly_iii/__init__.py new file mode 100644 index 00000000000..7ae33ed0ce0 --- /dev/null +++ b/tests/components/firefly_iii/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Firefly III integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/firefly_iii/conftest.py b/tests/components/firefly_iii/conftest.py new file mode 100644 index 00000000000..18250624ca7 --- /dev/null +++ b/tests/components/firefly_iii/conftest.py @@ -0,0 +1,95 @@ +"""Common fixtures for the Firefly III tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyfirefly.models import About, Account, Bill, Budget, Category, Currency +import pytest + +from homeassistant.components.firefly_iii.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_value_fixture, +) + +MOCK_TEST_CONFIG = { + CONF_URL: "https://127.0.0.1:8080/", + CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.firefly_iii.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_firefly_client() -> Generator[AsyncMock]: + """Mock Firefly client with dynamic exception injection support.""" + with ( + patch( + "homeassistant.components.firefly_iii.config_flow.Firefly" + ) as mock_client, + patch( + "homeassistant.components.firefly_iii.coordinator.Firefly", new=mock_client + ), + ): + client = mock_client.return_value + + client.get_about = AsyncMock( + return_value=About.from_dict(load_json_value_fixture("about.json", DOMAIN)) + ) + client.get_accounts = AsyncMock( + return_value=[ + Account.from_dict(account) + for account in load_json_array_fixture("accounts.json", DOMAIN) + ] + ) + client.get_categories = AsyncMock( + return_value=[ + Category.from_dict(category) + for category in load_json_array_fixture("categories.json", DOMAIN) + ] + ) + client.get_category = AsyncMock( + return_value=Category.from_dict( + load_json_value_fixture("category.json", DOMAIN) + ) + ) + client.get_currency_primary = AsyncMock( + return_value=Currency.from_dict( + load_json_value_fixture("primary_currency.json", DOMAIN) + ) + ) + client.get_budgets = AsyncMock( + return_value=[ + Budget.from_dict(budget) + for budget in load_json_array_fixture("budgets.json", DOMAIN) + ] + ) + client.get_bills = AsyncMock( + return_value=[ + Bill.from_dict(bill) + for bill in load_json_array_fixture("bills.json", DOMAIN) + ] + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Firefly III test", + data=MOCK_TEST_CONFIG, + entry_id="firefly_iii_test_entry_123", + ) diff --git a/tests/components/firefly_iii/fixtures/about.json b/tests/components/firefly_iii/fixtures/about.json new file mode 100644 index 00000000000..4d15af129df --- /dev/null +++ b/tests/components/firefly_iii/fixtures/about.json @@ -0,0 +1,7 @@ +{ + "version": "5.8.0-alpha.1", + "api_version": "5.8.0-alpha.1", + "php_version": "8.1.5", + "os": "Linux", + "driver": "mysql" +} diff --git a/tests/components/firefly_iii/fixtures/accounts.json b/tests/components/firefly_iii/fixtures/accounts.json new file mode 100644 index 00000000000..39c1f671f1e --- /dev/null +++ b/tests/components/firefly_iii/fixtures/accounts.json @@ -0,0 +1,178 @@ +[ + { + "type": "accounts", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "active": false, + "order": 1, + "name": "My checking account", + "type": "asset", + "account_role": "defaultAsset", + "currency_id": "12", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "12", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "current_balance": "123.45", + "native_current_balance": "123.45", + "current_balance_date": "2018-09-17T12:46:47+01:00", + "notes": "Some example notes", + "monthly_payment_date": "2018-09-17T12:46:47+01:00", + "credit_card_type": "monthlyFull", + "account_number": "7009312345678", + "iban": "GB98MIDL07009312345678", + "bic": "BOFAUS3N", + "virtual_balance": "123.45", + "native_virtual_balance": "123.45", + "opening_balance": "-1012.12", + "native_opening_balance": "-1012.12", + "opening_balance_date": "2018-09-17T12:46:47+01:00", + "liability_type": "loan", + "liability_direction": "credit", + "interest": "5.3", + "interest_period": "monthly", + "current_debt": "1012.12", + "include_net_worth": true, + "longitude": 5.916667, + "latitude": 51.983333, + "zoom_level": 6 + } + }, + { + "type": "accounts", + "id": "3", + "attributes": { + "created_at": "2019-01-01T10:00:00+01:00", + "updated_at": "2020-01-01T10:00:00+01:00", + "active": true, + "order": 2, + "name": "Savings Account", + "type": "expense", + "account_role": "savingsAsset", + "currency_id": "13", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "13", + "native_currency_code": "USD", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "current_balance": "5000.00", + "native_current_balance": "5000.00", + "current_balance_date": "2020-01-01T10:00:00+01:00", + "notes": "Main savings account", + "monthly_payment_date": null, + "credit_card_type": null, + "account_number": "1234567890", + "iban": "US12345678901234567890", + "bic": "CITIUS33", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "1000.00", + "native_opening_balance": "1000.00", + "opening_balance_date": "2019-01-01T10:00:00+01:00", + "liability_type": null, + "liability_direction": null, + "interest": "1.2", + "interest_period": "yearly", + "current_debt": null, + "include_net_worth": true, + "longitude": -74.006, + "latitude": 40.7128, + "zoom_level": 8 + } + }, + { + "type": "accounts", + "id": "4", + "attributes": { + "created_at": "2021-05-10T09:30:00+01:00", + "updated_at": "2022-05-10T09:30:00+01:00", + "active": true, + "order": 3, + "name": "Credit Card", + "type": "liability", + "account_role": "creditCard", + "currency_id": "14", + "currency_code": "GBP", + "currency_symbol": "£", + "currency_decimal_places": 2, + "native_currency_id": "14", + "native_currency_code": "GBP", + "native_currency_symbol": "£", + "native_currency_decimal_places": 2, + "current_balance": "-250.00", + "native_current_balance": "-250.00", + "current_balance_date": "2022-05-10T09:30:00+01:00", + "notes": "Credit card account", + "monthly_payment_date": "2022-05-15T09:30:00+01:00", + "credit_card_type": "monthlyFull", + "account_number": "9876543210", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2L", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "0.00", + "native_opening_balance": "0.00", + "opening_balance_date": "2021-05-10T09:30:00+01:00", + "liability_type": "credit", + "liability_direction": "debit", + "interest": "19.99", + "interest_period": "monthly", + "current_debt": "250.00", + "include_net_worth": false, + "longitude": 0.1278, + "latitude": 51.5074, + "zoom_level": 10 + } + }, + { + "type": "accounts", + "id": "4", + "attributes": { + "created_at": "2021-05-10T09:30:00+01:00", + "updated_at": "2022-05-10T09:30:00+01:00", + "active": true, + "order": 3, + "name": "Credit Card", + "type": "revenue", + "account_role": "creditCard", + "currency_id": "14", + "currency_code": "GBP", + "currency_symbol": "£", + "currency_decimal_places": 2, + "native_currency_id": "14", + "native_currency_code": "GBP", + "native_currency_symbol": "£", + "native_currency_decimal_places": 2, + "current_balance": "-250.00", + "native_current_balance": "-250.00", + "current_balance_date": "2022-05-10T09:30:00+01:00", + "notes": "Credit card account", + "monthly_payment_date": "2022-05-15T09:30:00+01:00", + "credit_card_type": "monthlyFull", + "account_number": "9876543210", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2L", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "0.00", + "native_opening_balance": "0.00", + "opening_balance_date": "2021-05-10T09:30:00+01:00", + "liability_type": "credit", + "liability_direction": "debit", + "interest": "19.99", + "interest_period": "monthly", + "current_debt": "250.00", + "include_net_worth": false, + "longitude": 0.1278, + "latitude": 51.5074, + "zoom_level": 10 + } + } +] diff --git a/tests/components/firefly_iii/fixtures/bills.json b/tests/components/firefly_iii/fixtures/bills.json new file mode 100644 index 00000000000..a59ee410581 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/bills.json @@ -0,0 +1,44 @@ +[ + { + "type": "bills", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "currency_id": "5", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "name": "Rent", + "amount_min": "123.45", + "amount_max": "123.45", + "native_amount_min": "123.45", + "native_amount_max": "123.45", + "date": "2018-09-17T12:46:47+01:00", + "end_date": "2018-09-17T12:46:47+01:00", + "extension_date": "2018-09-17T12:46:47+01:00", + "repeat_freq": "monthly", + "skip": 0, + "active": true, + "order": 1, + "notes": "Some example notes", + "next_expected_match": "2018-09-17T12:46:47+01:00", + "next_expected_match_diff": "today", + "object_group_id": "5", + "object_group_order": 5, + "object_group_title": "Example Group", + "pay_dates": ["2018-09-17T12:46:47+01:00"], + "paid_dates": [ + { + "transaction_group_id": "123", + "transaction_journal_id": "123", + "date": "2018-09-17T12:46:47+01:00" + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/budgets.json b/tests/components/firefly_iii/fixtures/budgets.json new file mode 100644 index 00000000000..39bd152e958 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/budgets.json @@ -0,0 +1,35 @@ +[ + { + "type": "budgets", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Bills", + "active": false, + "notes": "Some notes", + "order": 5, + "auto_budget_type": "reset", + "currency_id": "12", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "auto_budget_amount": "-1012.12", + "native_auto_budget_amount": "-1012.12", + "auto_budget_period": "monthly", + "spent": [ + { + "sum": "123.45", + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2 + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/categories.json b/tests/components/firefly_iii/fixtures/categories.json new file mode 100644 index 00000000000..ee7c7df2f58 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/categories.json @@ -0,0 +1,34 @@ +[ + { + "type": "categories", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Lunch", + "notes": "Some example notes", + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "spent": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "-12423.45" + } + ], + "earned": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "123.45" + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/category.json b/tests/components/firefly_iii/fixtures/category.json new file mode 100644 index 00000000000..415edb6ef0a --- /dev/null +++ b/tests/components/firefly_iii/fixtures/category.json @@ -0,0 +1,32 @@ +{ + "type": "categories", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Lunch", + "notes": "Some example notes", + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "spent": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "-12423.45" + } + ], + "earned": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "123.45" + } + ] + } +} diff --git a/tests/components/firefly_iii/fixtures/primary_currency.json b/tests/components/firefly_iii/fixtures/primary_currency.json new file mode 100644 index 00000000000..38472f84c55 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/primary_currency.json @@ -0,0 +1,15 @@ +{ + "type": "currencies", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "enabled": true, + "default": false, + "native": false, + "code": "AMS", + "name": "Ankh-Morpork dollar", + "symbol": "AM$", + "decimal_places": 2 + } +} diff --git a/tests/components/firefly_iii/snapshots/test_sensor.ambr b/tests/components/firefly_iii/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d381462e65a --- /dev/null +++ b/tests/components/firefly_iii/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_all_entities[sensor.firefly_iii_test_credit_card-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_credit_card', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:hand-coin', + 'original_name': 'Credit Card', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_4', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_credit_card-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'account_role': 'creditCard', + 'account_type': 'liability', + 'current_balance': '-250.00', + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Credit Card', + 'icon': 'mdi:hand-coin', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_credit_card', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-250.00', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_lunch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_lunch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lunch', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'category', + 'unique_id': 'None_category_2', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_lunch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Lunch', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_lunch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12300.0', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_my_checking_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:account-cash', + 'original_name': 'My checking account', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_2', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'account_role': 'defaultAsset', + 'account_type': 'asset', + 'current_balance': '123.45', + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test My checking account', + 'icon': 'mdi:account-cash', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_my_checking_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_savings_account-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_savings_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:cash-minus', + 'original_name': 'Savings Account', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_3', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_savings_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'account_role': 'savingsAsset', + 'account_type': 'expense', + 'current_balance': '5000.00', + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Savings Account', + 'icon': 'mdi:cash-minus', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_savings_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.00', + }) +# --- diff --git a/tests/components/firefly_iii/test_config_flow.py b/tests/components/firefly_iii/test_config_flow.py new file mode 100644 index 00000000000..99474ddccc3 --- /dev/null +++ b/tests/components/firefly_iii/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Firefly III config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest + +from homeassistant.components.firefly_iii.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_TEST_CONFIG + +from tests.common import MockConfigEntry + +MOCK_USER_SETUP = { + CONF_URL: "https://127.0.0.1:8080/", + CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, +} + + +async def test_form_and_flow( + hass: HomeAssistant, + mock_firefly_client: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test we get the form and can complete the 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"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:8080/" + assert result["data"] == MOCK_TEST_CONFIG + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + FireflyAuthenticationError, + "invalid_auth", + ), + ( + FireflyConnectionError, + "cannot_connect", + ), + ( + FireflyTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions.""" + mock_firefly_client.get_about.side_effect = exception + + 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"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_firefly_client.get_about.side_effect = None + 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"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:8080/" + assert result["data"] == MOCK_TEST_CONFIG + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + 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"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/firefly_iii/test_sensor.py b/tests/components/firefly_iii/test_sensor.py new file mode 100644 index 00000000000..9a26db29d18 --- /dev/null +++ b/tests/components/firefly_iii/test_sensor.py @@ -0,0 +1,31 @@ +"""Tests for the Firefly III sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.firefly_iii._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + )