Add SimpleFIN integration (#108336)

* reset to latest dev branch

* Update homeassistant/components/simplefin/sensor.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* formatting tweak

* Removed info errors

* fix bad billing error message

* addressing PR

* addressing PR

* reauth abort and already_confiugred added to strings.json

* adding the reauth message

* ruff

* update reqs

* reset to latest dev branch

* Update homeassistant/components/simplefin/sensor.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* formatting tweak

* Removed info errors

* fix bad billing error message

* addressing PR

* addressing PR

* reauth abort and already_confiugred added to strings.json

* adding the reauth message

* ruff

* update reqs

* Update homeassistant/components/simplefin/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Addressing a few PR comments - removing nix - and adding runtime data

* updated comments

* rename config flow

* pulling reauth :( - inline stuff

* inline more

* fixed a tab issue

* reverting changes

* various PR updates and code removal

* generator async add

* Update homeassistant/components/simplefin/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/simplefin/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/simplefin/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/simplefin/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/simplefin/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* always callable

* Update homeassistant/components/simplefin/coordinator.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* no-verify

* type

* fixing missing domain

* it looks like this file is gone now

* typing

* sorta pass

* fix license

* Update homeassistant/components/simplefin/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/simplefin/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* addressing PR

* Update homeassistant/components/simplefin/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* move property to entity.py

* moved stuff out to else block

* Initial Snappshot Testing ... still have unadressed changes to make

* Addressing PR Comments

* pushing back to joost

* removing non-needed file

* added more asserts

* reducing mocks - need to fix patch paths still

* Changed patch to be more localized to config_flow

* Removed unneeded fixture

* Moved coordinator around

* Cleaning up the code

* Removed a NOQA"

* Upping the number of asserts

* cleanup

* fixed abort call

* incremental update - for Josot... changed a function signature and removed an annotatoin

* no-verify

* Added an abort test

* ruff

* increased coverage but it might not pass muster for JOOST

* increased coverage but it might not pass muster for JOOST

* Much nicer test now

* tried to simplify

* Fix nits

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jeef 2024-07-10 04:44:04 -06:00 committed by GitHub
parent 42dcd693d5
commit 0213f1c5c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1307 additions and 0 deletions

View File

@ -1275,6 +1275,8 @@ build.json @home-assistant/supervisor
/tests/components/sighthound/ @robmarkcole
/homeassistant/components/signal_messenger/ @bbernhard
/tests/components/signal_messenger/ @bbernhard
/homeassistant/components/simplefin/ @scottg489 @jeeftor
/tests/components/simplefin/ @scottg489 @jeeftor
/homeassistant/components/simplepush/ @engrbm87
/tests/components/simplepush/ @engrbm87
/homeassistant/components/simplisafe/ @bachya

View File

@ -0,0 +1,33 @@
"""The SimpleFIN integration."""
from __future__ import annotations
from simplefin4py import SimpleFin
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_ACCESS_URL
from .coordinator import SimpleFinDataUpdateCoordinator
PLATFORMS: list[str] = [Platform.SENSOR]
type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool:
"""Set up from a config entry."""
access_url = entry.data[CONF_ACCESS_URL]
sf_client = SimpleFin(access_url)
sf_coordinator = SimpleFinDataUpdateCoordinator(hass, sf_client)
await sf_coordinator.async_config_entry_first_refresh()
entry.runtime_data = sf_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,75 @@
"""Config flow for SimpleFIN integration."""
from typing import Any
from simplefin4py import SimpleFin
from simplefin4py.exceptions import (
SimpleFinAuthError,
SimpleFinClaimError,
SimpleFinInvalidAccountURLError,
SimpleFinInvalidClaimTokenError,
SimpleFinPaymentRequiredError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import CONF_ACCESS_URL, DOMAIN, LOGGER
class SimpleFinConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for the initial setup of a SimpleFIN integration."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Prompt user for SimpleFIN API credentials."""
errors: dict[str, str] = {}
if user_input is not None:
access_url: str = user_input[CONF_ACCESS_URL]
self._async_abort_entries_match({CONF_ACCESS_URL: access_url})
try:
if not access_url.startswith("http"):
# Claim token detected - convert to access url
LOGGER.debug("[Setup Token] - Claiming Access URL")
access_url = await SimpleFin.claim_setup_token(access_url)
else:
LOGGER.debug("[Access Url] - 'http' string detected")
# Validate the access URL
LOGGER.debug("[Access Url] - validating access url")
SimpleFin.decode_access_url(access_url)
LOGGER.debug("[Access Url] - Fetching data")
simple_fin = SimpleFin(access_url=access_url)
await simple_fin.fetch_data()
except SimpleFinInvalidAccountURLError:
errors["base"] = "url_error"
except SimpleFinInvalidClaimTokenError:
errors["base"] = "invalid_claim_token"
except SimpleFinClaimError:
errors["base"] = "claim_error"
except SimpleFinPaymentRequiredError:
errors["base"] = "payment_required"
except SimpleFinAuthError:
errors["base"] = "invalid_auth"
else:
# We passed validation
user_input[CONF_ACCESS_URL] = access_url
return self.async_create_entry(
title="SimpleFIN",
data={CONF_ACCESS_URL: user_input[CONF_ACCESS_URL]},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ACCESS_URL): str,
}
),
errors=errors,
)

View File

@ -0,0 +1,12 @@
"""Constants for the SimpleFIN integration."""
from __future__ import annotations
import logging
from typing import Final
DOMAIN: Final = "simplefin"
LOGGER = logging.getLogger(__package__)
CONF_ACCESS_URL = "access_url"

View File

@ -0,0 +1,45 @@
"""Data update coordinator for the SimpleFIN integration."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from simplefin4py import FinancialData, SimpleFin
from simplefin4py.exceptions import SimpleFinAuthError, SimpleFinPaymentRequiredError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
class SimpleFinDataUpdateCoordinator(DataUpdateCoordinator[FinancialData]):
"""Data update coordinator for the SimpleFIN integration."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, client: SimpleFin) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=LOGGER,
name="simplefin",
update_interval=timedelta(hours=4),
)
self.client = client
async def _async_update_data(self) -> Any:
"""Fetch data for all accounts."""
try:
return await self.client.fetch_data()
except SimpleFinAuthError as err:
raise ConfigEntryError("Authentication failed") from err
except SimpleFinPaymentRequiredError as err:
LOGGER.warning(
"There is a billing issue with your SimpleFin account, contact Simplefin to address this issue"
)
raise UpdateFailed from err

View File

@ -0,0 +1,43 @@
"""SimpleFin Base Entity."""
from simplefin4py import Account
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
from .coordinator import SimpleFinDataUpdateCoordinator
class SimpleFinEntity(CoordinatorEntity[SimpleFinDataUpdateCoordinator]):
"""Define a generic class for SimpleFIN entities."""
_attr_attribution = "Data provided by SimpleFIN API"
_attr_has_entity_name = True
def __init__(
self,
coordinator: SimpleFinDataUpdateCoordinator,
description: EntityDescription,
account: Account,
) -> None:
"""Class initializer."""
super().__init__(coordinator)
self.entity_description = description
self._account_id = account.id
self._attr_unique_id = f"account_{account.id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, account.id)},
name=f"{account.org.name} {account.name}",
entry_type=DeviceEntryType.SERVICE,
manufacturer=account.org.name,
model=account.name,
)
@property
def account_data(self) -> Account:
"""Return the account data."""
return self.coordinator.data.get_account_for_id(self._account_id)

View File

@ -0,0 +1,11 @@
{
"domain": "simplefin",
"name": "SimpleFin",
"codeowners": ["@scottg489", "@jeeftor"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/simplefin",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["simplefin"],
"requirements": ["simplefin4py==0.0.16"]
}

View File

@ -0,0 +1,91 @@
"""Platform for sensor integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from simplefin4py import Account
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import SimpleFinConfigEntry
from .entity import SimpleFinEntity
@dataclass(frozen=True, kw_only=True)
class SimpleFinSensorEntityDescription(SensorEntityDescription):
"""Describes a sensor entity."""
value_fn: Callable[[Account], StateType]
icon_fn: Callable[[Account], str] | None = None
unit_fn: Callable[[Account], str] | None = None
SIMPLEFIN_SENSORS: tuple[SimpleFinSensorEntityDescription, ...] = (
SimpleFinSensorEntityDescription(
key="balance",
translation_key="balance",
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.MONETARY,
value_fn=lambda account: account.balance,
unit_fn=lambda account: account.currency,
icon_fn=lambda account: account.inferred_account_type,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SimpleFinConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up SimpleFIN sensors for config entries."""
sf_coordinator = config_entry.runtime_data
accounts = sf_coordinator.data.accounts
async_add_entities(
SimpleFinSensor(
sf_coordinator,
sensor_description,
account,
)
for account in accounts
for sensor_description in SIMPLEFIN_SENSORS
)
class SimpleFinSensor(SimpleFinEntity, SensorEntity):
"""Defines a SimpleFIN sensor."""
entity_description: SimpleFinSensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the state."""
return self.entity_description.value_fn(self.account_data)
@property
def icon(self) -> str | None:
"""Return the icon of this account."""
if self.entity_description.icon_fn is not None:
return self.entity_description.icon_fn(self.account_data)
return None
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the currency of this account."""
if self.entity_description.unit_fn:
return self.entity_description.unit_fn(self.account_data)
return None

View File

@ -0,0 +1,30 @@
{
"config": {
"step": {
"user": {
"description": "Please enter either a Claim Token or an Access URL.",
"data": {
"api_token": "Claim Token or Access URL"
}
}
},
"error": {
"invalid_auth": "Authentication failed: This could be due to revoked access or incorrect credentials",
"claim_error": "The claim token either does not exist or has already been used claimed by someone else. Receiving this could mean that the user\u2019s transaction information has been compromised",
"invalid_claim_token": "The claim token is invalid and could not be decoded",
"payment_required": "You presented a valid access url, however payment is required before you can obtain data",
"url_error": "There was an issue parsing the Account URL"
},
"abort": {
"missing_access_url": "Access URL or Claim Token missing",
"already_configured": "This Access URL is already configured."
}
},
"entity": {
"sensor": {
"balance": {
"name": "Balance"
}
}
}
}

View File

@ -499,6 +499,7 @@ FLOWS = {
"shelly",
"shopping_list",
"sia",
"simplefin",
"simplepush",
"simplisafe",
"skybell",

View File

@ -5432,6 +5432,12 @@
"config_flow": false,
"iot_class": "cloud_push"
},
"simplefin": {
"name": "SimpleFin",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"simplepush": {
"name": "Simplepush",
"integration_type": "hub",

View File

@ -2567,6 +2567,9 @@ sharp_aquos_rc==0.3.2
# homeassistant.components.shodan
shodan==1.28.0
# homeassistant.components.simplefin
simplefin4py==0.0.16
# homeassistant.components.sighthound
simplehound==0.3

View File

@ -1995,6 +1995,9 @@ sfrbox-api==0.0.8
# homeassistant.components.sharkiq
sharkiq==1.0.2
# homeassistant.components.simplefin
simplefin4py==0.0.16
# homeassistant.components.sighthound
simplehound==0.3

View File

@ -0,0 +1,13 @@
"""Tests for SimpleFin."""
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()

View File

@ -0,0 +1,83 @@
"""Test fixtures for SimpleFIN."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from simplefin4py import FinancialData
from simplefin4py.exceptions import SimpleFinInvalidClaimTokenError
from homeassistant.components.simplefin import CONF_ACCESS_URL
from homeassistant.components.simplefin.const import DOMAIN
from tests.common import MockConfigEntry, load_fixture
MOCK_ACCESS_URL = "https://i:am@yomama.house.com"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.simplefin.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture
async def mock_config_entry() -> MockConfigEntry:
"""Fixture for MockConfigEntry."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_ACCESS_URL: MOCK_ACCESS_URL},
version=1,
)
@pytest.fixture
def mock_claim_setup_token() -> str:
"""Fixture to mock the claim_setup_token method of SimpleFin."""
with patch(
"homeassistant.components.simplefin.config_flow.SimpleFin.claim_setup_token",
) as mock_claim_setup_token:
mock_claim_setup_token.return_value = "https://i:am@yomama.comma"
yield
@pytest.fixture
def mock_decode_claim_token_invalid_then_good() -> str:
"""Fixture to mock the decode_claim_token method of SimpleFin."""
return_values = [SimpleFinInvalidClaimTokenError, "valid_return_value"]
with patch(
"homeassistant.components.simplefin.config_flow.SimpleFin.decode_claim_token",
new_callable=lambda: MagicMock(side_effect=return_values),
):
yield
@pytest.fixture
def mock_simplefin_client() -> Generator[AsyncMock]:
"""Mock a SimpleFin client."""
with (
patch(
"homeassistant.components.simplefin.SimpleFin",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.simplefin.config_flow.SimpleFin",
new=mock_client,
),
):
mock_client.claim_setup_token.return_value = MOCK_ACCESS_URL
client = mock_client.return_value
fixture_data = load_fixture("fin_data.json", DOMAIN)
fin_data = FinancialData.from_json(fixture_data)
assert fin_data.accounts != []
client.fetch_data.return_value = fin_data
client.access_url = MOCK_ACCESS_URL
yield mock_client

View File

@ -0,0 +1,173 @@
{
"errors": [
"Connection to Investments may need attention",
"Connection to The Bank of Go may need attention"
],
"accounts": [
{
"org": {
"domain": "www.newwealthfront.com",
"name": "The Bank of Go",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.newwealthfront.com"
},
"id": "ACT-1a2b3c4d-5e6f-7g8h-9i0j",
"name": "The Bank",
"currency": "USD",
"balance": "7777.77",
"available-balance": "7777.77",
"balance-date": 1705413843,
"transactions": [
{
"id": "12394832938403",
"posted": 793090572,
"amount": "-1234.56",
"description": "Enchanted Bait Shop",
"payee": "Uncle Frank",
"memo": "Some memo",
"transacted_at": 793080572
}
],
"extra": {
"account-open-date": 978360153
},
"holdings": []
},
{
"org": {
"domain": "www.newfidelity.com",
"name": "Investments",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.newfidelity.com"
},
"id": "ACT-1k2l3m4n-5o6p-7q8r-9s0t",
"name": "My Checking",
"currency": "USD",
"balance": "12345.67",
"available-balance": "5432.10",
"balance-date": 1705413319,
"transactions": [],
"holdings": []
},
{
"org": {
"domain": "www.newhfcu.org",
"name": "The Bank of Go",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.newhfcu.org/"
},
"id": "ACT-2a3b4c5d-6e7f-8g9h-0i1j",
"name": "PRIME SAVINGS",
"currency": "EUR",
"balance": "9876.54",
"available-balance": "8765.43",
"balance-date": 1705428861,
"transactions": [],
"holdings": []
},
{
"org": {
"domain": "www.randombank2.com",
"name": "Random Bank",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.randombank2.com/"
},
"id": "ACT-3a4b5c6d-7e8f-9g0h-1i2j",
"name": "Costco Anywhere Visa® Card",
"currency": "USD",
"balance": "-532.69",
"available-balance": "4321.98",
"balance-date": 1705429002,
"transactions": [],
"holdings": []
},
{
"org": {
"domain": "www.newfidelity.com",
"name": "Investments",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.newfidelity.com"
},
"id": "ACT-4k5l6m7n-8o9p-1q2r-3s4t",
"name": "Dr Evil",
"currency": "USD",
"balance": "1000000.00",
"available-balance": "13579.24",
"balance-date": 1705413319,
"transactions": [],
"holdings": [
{
"id": "HOL-62eb5bb6-4aed-4fe1-bdbe-f28e127e359b",
"created": 1705413320,
"currency": "",
"cost_basis": "10000.00",
"description": "Fantastic FID GROWTH CO K6",
"market_value": "15000.00",
"purchase_price": "0.00",
"shares": "200.00",
"symbol": "FGKFX"
}
]
},
{
"org": {
"domain": "www.newfidelity.com",
"name": "Investments",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.newfidelity.com"
},
"id": "ACT-5k6l7m8n-9o0p-1q2r-3s4t",
"name": "NerdCorp Series B",
"currency": "EUR",
"balance": "13579.24",
"available-balance": "9876.54",
"balance-date": 1705413319,
"transactions": [],
"holdings": [
{
"id": "HOL-08f775cd-eedf-4ee5-9f53-241c8efa5bf3",
"created": 1705413321,
"currency": "",
"cost_basis": "7500.00",
"description": "Mythical FID GROWTH CO K6",
"market_value": "9876.54",
"purchase_price": "0.00",
"shares": "150.00",
"symbol": "FGKFX"
}
]
},
{
"org": {
"domain": "www.randombank2.com",
"name": "Mythical RandomSavings",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.randombank2.com/"
},
"id": "ACT-6a7b8c9d-0e1f-2g3h-4i5j",
"name": "Unicorn Pot",
"currency": "USD",
"balance": "10000.00",
"available-balance": "7500.00",
"balance-date": 1705429002,
"transactions": [],
"holdings": []
},
{
"org": {
"domain": "www.randombank2.com",
"name": "Mythical RandomSavings",
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
"url": "https://www.randombank2.com/"
},
"id": "ACT-7a8b9c0d-1e2f-3g4h-5i6j",
"name": "Castle Mortgage",
"currency": "USD",
"balance": "7500.00",
"available-balance": "5000.00",
"balance-date": 1705429002,
"transactions": [],
"holdings": []
}
]
}

View File

@ -0,0 +1,425 @@
# serializer version: 1
# name: test_all_entities[sensor.investments_dr_evil_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.investments_dr_evil_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.INVESTMENT: 'mdi:chart-areaspline'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-4k5l6m7n-8o9p-1q2r-3s4t_balance',
'unit_of_measurement': 'USD',
})
# ---
# name: test_all_entities[sensor.investments_dr_evil_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'Investments Dr Evil Balance',
'icon': <AccountType.INVESTMENT: 'mdi:chart-areaspline'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'USD',
}),
'context': <ANY>,
'entity_id': 'sensor.investments_dr_evil_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1000000.00',
})
# ---
# name: test_all_entities[sensor.investments_my_checking_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.investments_my_checking_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.CHECKING: 'mdi:checkbook'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-1k2l3m4n-5o6p-7q8r-9s0t_balance',
'unit_of_measurement': 'USD',
})
# ---
# name: test_all_entities[sensor.investments_my_checking_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'Investments My Checking Balance',
'icon': <AccountType.CHECKING: 'mdi:checkbook'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'USD',
}),
'context': <ANY>,
'entity_id': 'sensor.investments_my_checking_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12345.67',
})
# ---
# name: test_all_entities[sensor.investments_nerdcorp_series_b_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.investments_nerdcorp_series_b_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.INVESTMENT: 'mdi:chart-areaspline'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-5k6l7m8n-9o0p-1q2r-3s4t_balance',
'unit_of_measurement': 'EUR',
})
# ---
# name: test_all_entities[sensor.investments_nerdcorp_series_b_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'Investments NerdCorp Series B Balance',
'icon': <AccountType.INVESTMENT: 'mdi:chart-areaspline'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'EUR',
}),
'context': <ANY>,
'entity_id': 'sensor.investments_nerdcorp_series_b_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '13579.24',
})
# ---
# name: test_all_entities[sensor.mythical_randomsavings_castle_mortgage_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mythical_randomsavings_castle_mortgage_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.UNKNOWN: 'mdi:cash'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-7a8b9c0d-1e2f-3g4h-5i6j_balance',
'unit_of_measurement': 'USD',
})
# ---
# name: test_all_entities[sensor.mythical_randomsavings_castle_mortgage_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'Mythical RandomSavings Castle Mortgage Balance',
'icon': <AccountType.UNKNOWN: 'mdi:cash'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'USD',
}),
'context': <ANY>,
'entity_id': 'sensor.mythical_randomsavings_castle_mortgage_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '7500.00',
})
# ---
# name: test_all_entities[sensor.mythical_randomsavings_unicorn_pot_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mythical_randomsavings_unicorn_pot_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.UNKNOWN: 'mdi:cash'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-6a7b8c9d-0e1f-2g3h-4i5j_balance',
'unit_of_measurement': 'USD',
})
# ---
# name: test_all_entities[sensor.mythical_randomsavings_unicorn_pot_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'Mythical RandomSavings Unicorn Pot Balance',
'icon': <AccountType.UNKNOWN: 'mdi:cash'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'USD',
}),
'context': <ANY>,
'entity_id': 'sensor.mythical_randomsavings_unicorn_pot_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10000.00',
})
# ---
# name: test_all_entities[sensor.random_bank_costco_anywhere_visa_r_card_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.random_bank_costco_anywhere_visa_r_card_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.CREDIT_CARD: 'mdi:credit-card'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-3a4b5c6d-7e8f-9g0h-1i2j_balance',
'unit_of_measurement': 'USD',
})
# ---
# name: test_all_entities[sensor.random_bank_costco_anywhere_visa_r_card_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'Random Bank Costco Anywhere Visa® Card Balance',
'icon': <AccountType.CREDIT_CARD: 'mdi:credit-card'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'USD',
}),
'context': <ANY>,
'entity_id': 'sensor.random_bank_costco_anywhere_visa_r_card_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-532.69',
})
# ---
# name: test_all_entities[sensor.the_bank_of_go_prime_savings_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.the_bank_of_go_prime_savings_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.SAVINGS: 'mdi:piggy-bank-outline'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-2a3b4c5d-6e7f-8g9h-0i1j_balance',
'unit_of_measurement': 'EUR',
})
# ---
# name: test_all_entities[sensor.the_bank_of_go_prime_savings_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'The Bank of Go PRIME SAVINGS Balance',
'icon': <AccountType.SAVINGS: 'mdi:piggy-bank-outline'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'EUR',
}),
'context': <ANY>,
'entity_id': 'sensor.the_bank_of_go_prime_savings_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9876.54',
})
# ---
# name: test_all_entities[sensor.the_bank_of_go_the_bank_balance-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.the_bank_of_go_the_bank_balance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': <AccountType.UNKNOWN: 'mdi:cash'>,
'original_name': 'Balance',
'platform': 'simplefin',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'balance',
'unique_id': 'account_ACT-1a2b3c4d-5e6f-7g8h-9i0j_balance',
'unit_of_measurement': 'USD',
})
# ---
# name: test_all_entities[sensor.the_bank_of_go_the_bank_balance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by SimpleFIN API',
'device_class': 'monetary',
'friendly_name': 'The Bank of Go The Bank Balance',
'icon': <AccountType.UNKNOWN: 'mdi:cash'>,
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'USD',
}),
'context': <ANY>,
'entity_id': 'sensor.the_bank_of_go_the_bank_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '7777.77',
})
# ---

View File

@ -0,0 +1,164 @@
"""Test config flow."""
from unittest.mock import AsyncMock
import pytest
from simplefin4py.exceptions import (
SimpleFinAuthError,
SimpleFinClaimError,
SimpleFinInvalidAccountURLError,
SimpleFinInvalidClaimTokenError,
SimpleFinPaymentRequiredError,
)
from homeassistant.components.simplefin import CONF_ACCESS_URL
from homeassistant.components.simplefin.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from .conftest import MOCK_ACCESS_URL
from tests.common import MockConfigEntry
async def test_successful_claim(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_simplefin_client: AsyncMock,
) -> None:
"""Test successful token claim in config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: "donJulio"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "SimpleFIN"
assert result["data"] == {CONF_ACCESS_URL: MOCK_ACCESS_URL}
async def test_already_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_simplefin_client: AsyncMock,
) -> None:
"""Test all entities."""
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
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: MOCK_ACCESS_URL},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_access_url(
hass: HomeAssistant,
mock_simplefin_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test standard config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: "http://user:password@string"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_ACCESS_URL] == "http://user:password@string"
assert result["title"] == "SimpleFIN"
@pytest.mark.parametrize(
("side_effect", "error_key"),
[
(SimpleFinInvalidAccountURLError, "url_error"),
(SimpleFinPaymentRequiredError, "payment_required"),
(SimpleFinAuthError, "invalid_auth"),
],
)
async def test_access_url_errors(
hass: HomeAssistant,
mock_simplefin_client: AsyncMock,
side_effect: Exception,
error_key: str,
) -> None:
"""Test the various errors we can get in access_url mode."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
mock_simplefin_client.claim_setup_token.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: "donJulio"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_key}
mock_simplefin_client.claim_setup_token.side_effect = None
# Pass the entry creation
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: "http://user:password@string"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_ACCESS_URL: "http://user:password@string"}
assert result["title"] == "SimpleFIN"
@pytest.mark.parametrize(
("side_effect", "error_key"),
[
(SimpleFinInvalidClaimTokenError, "invalid_claim_token"),
(SimpleFinClaimError, "claim_error"),
],
)
async def test_claim_token_errors(
hass: HomeAssistant,
mock_simplefin_client: AsyncMock,
side_effect: Exception,
error_key: str,
) -> None:
"""Test config flow with various token claim errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
mock_simplefin_client.claim_setup_token.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: "donJulio"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_key}
mock_simplefin_client.claim_setup_token.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ACCESS_URL: "donJulio"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_ACCESS_URL: "https://i:am@yomama.house.com"}
assert result["title"] == "SimpleFIN"

View File

@ -0,0 +1,94 @@
"""Test SimpleFin Sensor with Snapshot data."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from simplefin4py.exceptions import SimpleFinAuthError, SimpleFinPaymentRequiredError
from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_simplefin_client: AsyncMock,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.simplefin.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("side_effect"),
[
(SimpleFinAuthError),
(SimpleFinPaymentRequiredError),
],
)
async def test_update_errors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_simplefin_client: AsyncMock,
freezer: FrozenDateTimeFactory,
side_effect: Exception,
) -> None:
"""Test connection error."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.the_bank_of_go_the_bank_balance").state == "7777.77"
assert hass.states.get("sensor.investments_my_checking_balance").state == "12345.67"
assert (
hass.states.get("sensor.the_bank_of_go_prime_savings_balance").state
== "9876.54"
)
assert (
hass.states.get("sensor.random_bank_costco_anywhere_visa_r_card_balance").state
== "-532.69"
)
assert hass.states.get("sensor.investments_dr_evil_balance").state == "1000000.00"
assert (
hass.states.get("sensor.investments_nerdcorp_series_b_balance").state
== "13579.24"
)
assert (
hass.states.get("sensor.mythical_randomsavings_unicorn_pot_balance").state
== "10000.00"
)
assert (
hass.states.get("sensor.mythical_randomsavings_castle_mortgage_balance").state
== "7500.00"
)
mock_simplefin_client.return_value.fetch_data.side_effect = side_effect
freezer.tick(timedelta(days=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
sensors = [
"sensor.the_bank_of_go_the_bank_balance",
"sensor.investments_my_checking_balance",
"sensor.the_bank_of_go_prime_savings_balance",
"sensor.random_bank_costco_anywhere_visa_r_card_balance",
"sensor.investments_dr_evil_balance",
"sensor.investments_nerdcorp_series_b_balance",
"sensor.mythical_randomsavings_unicorn_pot_balance",
"sensor.mythical_randomsavings_castle_mortgage_balance",
]
for sensor in sensors:
assert hass.states.get(sensor).state == STATE_UNAVAILABLE