From 28126af3fc4056ddb198ac5a90f63ff1092f3fe3 Mon Sep 17 00:00:00 2001 From: Carl Sutton Date: Mon, 10 Jan 2022 09:56:23 +0100 Subject: [PATCH] wip --- .devcontainer/README.md | 60 ++ .devcontainer/configuration.yaml | 9 + .devcontainer/devcontainer.json | 30 + .gitattributes | 1 + .github/ISSUE_TEMPLATE/feature_request.md | 21 + .github/ISSUE_TEMPLATE/issue.md | 42 + .github/workflows/pr.yaml | 19 - .github/workflows/sdlc.yaml | 76 ++ .gitignore | 16 + .vscode/launch.json | 35 + .vscode/settings.json | 9 + .vscode/tasks.json | 86 ++ CONTRIBUTING.md | 60 ++ LICENSE | 21 + Makefile | 50 + README.md | 39 +- VERSION | 1 + __init__.py | 66 -- custom_components/__init__.py | 1 + custom_components/nordigen/__init__.py | 185 ++++ custom_components/nordigen/config_flow.py | 161 ++++ .../nordigen/manifest.json | 15 +- custom_components/nordigen/ng.py | 140 +++ custom_components/nordigen/sensor.py | 483 ++++++++++ custom_components/nordigen/strings.json | 40 + .../nordigen/translations/en.json | 40 + hacs.json | 24 +- info.md | 56 ++ pyproject.toml | 3 + renovate.json | 16 +- requirements_dev.txt | 1 + requirements_test.txt | 1 + sensor.py | 36 - setup.cfg | 47 + setup.py | 67 ++ sonar-project.properties | 13 + tests/README.md | 24 + tests/__init__.py | 18 + tests/test_config_flow.py | 155 ++++ tests/test_init.py | 153 ++++ tests/test_integration.py | 193 ++++ tests/test_ng.py | 317 +++++++ tests/test_sensors.py | 865 ++++++++++++++++++ 43 files changed, 3530 insertions(+), 165 deletions(-) create mode 100644 .devcontainer/README.md create mode 100644 .devcontainer/configuration.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/issue.md delete mode 100644 .github/workflows/pr.yaml create mode 100644 .github/workflows/sdlc.yaml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 VERSION delete mode 100644 __init__.py create mode 100644 custom_components/__init__.py create mode 100644 custom_components/nordigen/__init__.py create mode 100644 custom_components/nordigen/config_flow.py rename manifest.json => custom_components/nordigen/manifest.json (57%) create mode 100644 custom_components/nordigen/ng.py create mode 100644 custom_components/nordigen/sensor.py create mode 100644 custom_components/nordigen/strings.json create mode 100644 custom_components/nordigen/translations/en.json create mode 100644 info.md create mode 100644 pyproject.toml create mode 100644 requirements_dev.txt create mode 100644 requirements_test.txt delete mode 100644 sensor.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 sonar-project.properties create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_init.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_ng.py create mode 100644 tests/test_sensors.py diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..e304a9a --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. + +### Step by Step debugging + +With the development container, +you can test your custom component in Home Assistant with step by step debugging. + +You need to modify the `configuration.yaml` file in `.devcontainer` folder +by uncommenting the line: + +```yaml +# debugpy: +``` + +Then launch the task `Run Home Assistant on port 9123`, and launch the debugger +with the existing debugging configuration `Python: Attach Local`. + +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..8dff125 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,9 @@ +default_config: + +logger: + default: info + logs: + custom_components.nordigen: debug + +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +# debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7d6ca3a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "Blueprint integration development", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..405f663 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +## Problem Statement + + + +## Potential Solution + + + +## Alternatives + + + +## Additional Context + + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..93029fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your logs here or screen shot of config flow + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml deleted file mode 100644 index 3b1961c..0000000 --- a/.github/workflows/pr.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: "Validation And Formatting" -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" -jobs: - ci: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - name: Checkout - - uses: "hacs/action@main" - name: HACS Action - with: - CATEGORY: integration - - uses: "home-assistant/actions/hassfest@master" - name: Hass Fest - diff --git a/.github/workflows/sdlc.yaml b/.github/workflows/sdlc.yaml new file mode 100644 index 0000000..04ea824 --- /dev/null +++ b/.github/workflows/sdlc.yaml @@ -0,0 +1,76 @@ +name: SDLC Workflow + +on: + pull_request: + push: + branches: + - master + schedule: + - cron: '0 0 * * *' + +jobs: + all-tests: + uses: dogmatic69/nordigen-python/.github/workflows/all-test.yaml@master + secrets: + github_key: ${{ secrets.GITHUB_TOKEN }} + sonar_key: ${{ secrets.SONAR_TOKEN }} + + publish: + uses: dogmatic69/nordigen-python/.github/workflows/pypi-publish.yaml@master + if: github.ref == 'refs/heads/master' + needs: + - all-tests + with: + python_version: '3.10' + package_name: nordigen-homeassistant + secrets: + pypi_key: ${{ secrets.PYPI_TOKEN }} + + validate: + runs-on: ubuntu-latest + name: Validate + steps: + - uses: actions/checkout@v2 + + - name: HACS validation + uses: hacs/action@main + with: + category: integration + + - name: Hassfest validation + uses: home-assistant/actions/hassfest@master + + style: + runs-on: ubuntu-latest + name: Check style formatting + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v1 + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: ubuntu-latest + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.nordigen \ + -o console_output_style=count \ + -p no:sugar \ + tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e5d069 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# testing +coverage.xml +pytest-report.xml +.coverage +htmlcov + +# python +.python +*egg* +**/__pycache__ +pythonenv* +venv +.venv + +# build +dist diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..555a62b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] + } + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..605b24e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python.linting.pylintEnabled": false, + "python.linting.enabled": true, + "python.pythonPath": "/usr/local/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + }, + "python.linting.flake8Enabled": true +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..63fd720 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,86 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + }, + { + "label": "Pytest", + "type": "shell", + "command": "pytest --timeout=10 tests", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Code Coverage", + "detail": "Generate code coverage report for a given integration.", + "type": "shell", + "command": "pytest ./tests/ --cov=homeassistant.components.nordigen --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Install all Requirements", + "type": "shell", + "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_dev.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Install all Test Requirements", + "type": "shell", + "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..105c3c2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +1. If you've changed something, update the documentation. +1. Make sure your code passes all checks and tests +1. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) to make sure the code follows the style. + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/dogmatic69/nordigen-homeassistant). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..39118a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Carl Sutton @dogmatic69 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f112cee --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: build +build: + rm dist/* || true + python setup.py sdist + +.PHONY: isort +isort: + isort ./custom_components/nordigen ./tests --check-only + +.PHONY: black +black: + black --check ./custom_components/nordigen ./tests + +.PHONY: flake8 +flake8: + flake8 ./custom_components/nordigen ./tests + +.PHONY: test +test: + pytest -vv -x + +.PHONY: ci +ci: isort black flake8 test + +.PHONY: ci-fix +ci-fix: + isort ./custom_components/nordigen ./tests + black ./custom_components/nordigen ./tests + +.PHONY: dev +dev: + $(MAKE) ci-fix + $(MAKE) ci + +.PHONY: install-pip +install-pip: + python -m pip install --upgrade pip==20.2 + +.PHONY: install-dev +install-dev: install-pip + pip install -e ".[dev]" + +.PHONY: install-publish +install-publish: install-pip + pip install -e ".[publish]" + +.PHONY: publish +publish: build + twine upload --verbose dist/* + diff --git a/README.md b/README.md index 895ea4d..e771b9c 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,30 @@ [![GitHub](https://img.shields.io/github/license/dogmatic69/nordigen-homeassistant)](LICENSE) [![CodeFactor](https://www.codefactor.io/repository/github/dogmatic69/nordigen-homeassistant/badge)](https://www.codefactor.io/repository/github/dogmatic69/nordigen-homeassistant) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=alert_status)](https://sonarcloud.io/dashboard?id=dogmatic69_nordigen-homeassistant) -[![CI](https://github.com/dogmatic69/nordigen-homeassistant/actions/workflows/pr.yaml/badge.svg)](https://github.com/dogmatic69/nordigen-homeassistant/actions/workflows/pr.yaml) - -This integration will allow you to have access to banking data for most banks -in the EU. +[![SDLC](https://github.com/dogmatic69/nordigen-homeassistant/actions/workflows/pr.yaml/badge.svg)](https://github.com/dogmatic69/nordigen-homeassistant/actions/workflows/sdlc.yaml) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=bugs)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-homeassistant&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=dogmatic69_nordigen-homeassistant) +[![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) +This integration will allow you to have access to banking data for most banks in the EU. ## Installation HACS coming soon! -You will need to register on Nordigen and get an API key before you can run this -integration. At this time there is only support for one API key per HA instance. +You will need to register on Nordigen and get an API key before you can run this integration. At this time there is only support for one API key per HA instance. ![Example bank sensor](/pics/sensor-examle.png) ### Installation -1. Copy / clone this integration into `./config/custom_components/nordigen` +1. (HACS) Search for "nordigen" in HACS and install. (Skip manual step). +1. (manual) Copy / clone this integration into `./config/custom_components/nordigen` 1. Restart Home Assistant to get the integration loaded ### Configuraiton @@ -35,8 +42,7 @@ integration. At this time there is only support for one API key per HA instance. 1. Click the link and follow the instructions 1. Restart Home Assistant one last time -In the future I hope to be able to make the system a bit more dynamic and user -friendly for account onboarding :) +In the future I hope to be able to make the system a bit more dynamic and user friendly for account onboarding :) ## Configuration @@ -140,26 +146,15 @@ automation: ## Technical details -I wanted to have good test coverage and could not find a good way to do it within -this integration so I've abstracted out all the code into a [standalone python lib]. - -That in turn uses a generic API [clinet lib] for Nordigen +This lib uses the generic [Nordigen client lib](https://github.com/dogmatic69/nordigen-python) to provide all the logic required for fetching data from the Nordigen system. ## About Nordigen -[Nordigen] is an all-in-one banking data API for building powerful banking, lending -and finance apps. They offer a free API for fetching account info, balances and -transactions. They also handle all the authentication between the banks and do -a little bit of data nomilisation. +[Nordigen] is an all-in-one banking data API for building powerful banking, lending and finance apps. They offer a free API for fetching account info, balances and transactions. They also handle all the authentication between the banks and do a little bit of data nomilisation. Check out the [Nordigen API] for full details. - - -[standalone python lib]: https://pypi.org/project/nordigen-ha-lib/ [client lib]: https://pypi.org/project/nordigen-python/ [Nordigen]: https://nordigen.com/ [Nordigen API]: https://nordigen.com/en/account_information_documenation/api-documention/overview/ - - diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..79a2734 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.5.0 \ No newline at end of file diff --git a/__init__.py b/__init__.py deleted file mode 100644 index af22eba..0000000 --- a/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Nordigen Platform integration.""" -import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -from nordigen_lib import config_schema, entry - -DOMAIN = "nordigen" -CONST = { - "DOMAIN": DOMAIN, - "DOMAIN_DATA": "{}_data".format(DOMAIN), - "TOKEN": "token", - "DEBUG": "debug", - "ENDUSER_ID": "enduser_id", - "ASPSP_ID": "aspsp_id", - "AVAILABLE_BALANCE": "available_balance", - "BOOKED_BALANCE": "booked_balance", - "TRANSACTIONS": "transactions", - "REQUISITIONS": "requisitions", - "HISTORICAL_DAYS": "max_historical_days", - "REFRESH_RATE": "refresh_rate", - "IGNORE_ACCOUNTS": "ignore_accounts", - "ICON_FIELD": "icon", - "ICON": { - "default": "mdi:currency-usd-circle", - "auth": "mdi:two-factor-authentication", - "sign": "mdi:currency-sign", - "eur-off": "mdi:currency-eur-off", - "usd-circle": "mdi:currency-usd-circle", - "usd-circle-outline": "mdi:currency-usd-circle-outline", - "usd-off": "mdi:currency-usd-off", - "BDT": "mdi:currency-bdt", - "BRL": "mdi:currency-brl", - "BTC": "mdi:currency-btc", - "CNY": "mdi:currency-cny", - "ETH": "mdi:currency-eth", - "EUR": "mdi:currency-eur", - "GBP": "mdi:currency-gbp", - "ILS": "mdi:currency-ils", - "INR": "mdi:currency-inr", - "JPY": "mdi:currency-jpy", - "KRW": "mdi:currency-krw", - "KZT": "mdi:currency-kzt", - "NGN": "mdi:currency-ngn", - "PHP": "mdi:currency-php", - "RIAL": "mdi:currency-rial", - "RUB": "mdi:currency-rub", - "TRY": "mdi:currency-try", - "TWD": "mdi:currency-twd", - "USD": "mdi:currency-usd", - }, -} - -CONFIG_SCHEMA = config_schema( - vol, - cv, - CONST, -) - -LOGGER = logging.getLogger(__package__) - - -def setup(hass, config): - """Setup the Nordigen platform.""" - return entry(hass, config, CONST=CONST, LOGGER=LOGGER) diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..f55f54d --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom components module.""" diff --git a/custom_components/nordigen/__init__.py b/custom_components/nordigen/__init__.py new file mode 100644 index 0000000..50bb57a --- /dev/null +++ b/custom_components/nordigen/__init__.py @@ -0,0 +1,185 @@ +"""Nordigen Platform integration.""" +import asyncio +import logging + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from .ng import get_client, get_requisitions + +NAME = "Nordigen HomeAssistant" +ISSUE_URL = "https://github.com/dogmatic69/nordigen-homeassistant/issues" +DOMAIN = "nordigen" +PLATFORMS = ["sensor"] + +with open("VERSION", "r") as buf: + VERSION = buf.read() + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" + +const = { + "DOMAIN": DOMAIN, + "DOMAIN_DATA": f"{DOMAIN}_data", + "SECRET_ID": "secret_id", + "SECRET_KEY": "secret_key", + "DEBUG": "debug", + "ENDUSER_ID": "enduser_id", + "INSTITUTION_ID": "institution_id", + "BALANCE_TYPES": "balance_types", + "ACCOUNT_HOLDER": "account_holder", + "REQUISITION_STATUS": "requisition_status", + "TRANSACTIONS": "transactions", + "REQUISITIONS": "requisitions", + "HISTORICAL_DAYS": "max_historical_days", + "REFRESH_RATE": "refresh_rate", + "IGNORE_ACCOUNTS": "ignore_accounts", + "COUNTRY_FIELD": "country", + "COUNTRIES": ["SE", "GB"], + "ICON_FIELD": "icon", + "ICON": { + "default": "mdi:cash-100", + "auth": "mdi:two-factor-authentication", + "sign": "mdi:currency-sign", + "eur-off": "mdi:currency-eur-off", + "usd-off": "mdi:currency-usd-off", + "BDT": "mdi:currency-bdt", + "BRL": "mdi:currency-brl", + "BTC": "mdi:currency-btc", + "CNY": "mdi:currency-cny", + "ETH": "mdi:currency-eth", + "EUR": "mdi:currency-eur", + "GBP": "mdi:currency-gbp", + "ILS": "mdi:currency-ils", + "INR": "mdi:currency-inr", + "JPY": "mdi:currency-jpy", + "KRW": "mdi:currency-krw", + "KZT": "mdi:currency-kzt", + "NGN": "mdi:currency-ngn", + "PHP": "mdi:currency-php", + "RIAL": "mdi:currency-rial", + "RUB": "mdi:currency-rub", + "TRY": "mdi:currency-try", + "TWD": "mdi:currency-twd", + "USD": "mdi:currency-usd", + }, +} + +CONFIG_SCHEMA = vol.Schema( + { + const["DOMAIN"]: vol.Schema( + { + vol.Required(const["SECRET_ID"]): cv.string, + vol.Required(const["SECRET_KEY"]): cv.string, + vol.Optional(const["DEBUG"], default=False): cv.string, + vol.Required(const["REQUISITIONS"]): [ + { + vol.Required(const["ENDUSER_ID"]): cv.string, + vol.Required(const["INSTITUTION_ID"]): cv.string, + vol.Optional(const["REFRESH_RATE"], default=240): cv.string, + vol.Optional(const["BALANCE_TYPES"], default=[]): [cv.string], + vol.Optional(const["HISTORICAL_DAYS"], default=30): cv.string, + vol.Optional(const["IGNORE_ACCOUNTS"], default=[]): [cv.string], + vol.Optional(const["ICON_FIELD"], default="mdi:cash-100"): cv.string, + }, + ], + }, + extra=vol.ALLOW_EXTRA, + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +logger = logging.getLogger(__package__) + + +def get_config(configs, requisition): + for config in configs: + ref = f"{config['enduser_id']}-{config['institution_id']}" + if requisition["reference"] == ref: + return config + + +def setup(hass, config): + domain_config = config.get(const["DOMAIN"]) + if domain_config is None: + logger.warning("Nordigen not configured") + return True + + logger.debug("config: %s", domain_config) + client = get_client(secret_id=domain_config[const["SECRET_ID"]], secret_key=domain_config[const["SECRET_KEY"]]) + hass.data[const["DOMAIN"]] = { + "client": client, + } + + requisitions = get_requisitions( + client=client, + configs=domain_config[const["REQUISITIONS"]], + logger=logger, + const=const, + ) + + discovery = { + "requisitions": requisitions, + } + + for platform in PLATFORMS: + hass.helpers.discovery.load_platform(platform, const["DOMAIN"], discovery, config) + + return True + + +async def async_setup(hass, config): + return True + + +async def async_setup_entry(hass, entry): + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + logger.info(STARTUP_MESSAGE) + + # coordinator = BlueprintDataUpdateCoordinator(hass, client=client) + # await coordinator.async_refresh() + + # if not coordinator.last_update_success: + # raise ConfigEntryNotReady + + # hass.data[DOMAIN][entry.entry_id] = coordinator + + # for platform in PLATFORMS: + # if entry.options.get(platform, True): + # coordinator.platforms.append(platform) + # hass.async_add_job(hass.config_entries.async_forward_entry_setup(entry, platform)) + + # entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + # return True + + +async def async_unload_entry(hass, entry) -> bool: + coordinator = hass.data[DOMAIN][entry.entry_id] + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + if platform in coordinator.platforms + ] + ) + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) + + return unloaded + + +async def async_reload_entry(hass, entry) -> None: + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) diff --git a/custom_components/nordigen/config_flow.py b/custom_components/nordigen/config_flow.py new file mode 100644 index 0000000..cb9cb0e --- /dev/null +++ b/custom_components/nordigen/config_flow.py @@ -0,0 +1,161 @@ +from collections import OrderedDict + +from homeassistant import config_entries +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from custom_components.nordigen.ng import get_client +from . import DOMAIN, const + +secret_id = None +secret_key = None + + +def valid_country(country): + if str(country).upper() not in const["COUNTRIES"]: + raise vol.Invalid("Unsuppored country specified") + + return str(country).upper() + + +def get_institutions(fn, country): + def get(): + return fn(country) + + return get + + +def create_req(fn, **kwargs): + def job(): + return fn(**kwargs) + + return job + + +class NordigenConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + entry: config_entries.ConfigEntry + + async def get_requisition(self, requisitions, institution_id, account_holder): + reference = f"{account_holder}-{institution_id}" + res = await self.hass.async_add_executor_job(requisitions.list) + res = res.get("results", []) + res = [requisition for requisition in res if requisition["reference"] == reference] + requisition = res[0] if len(res) > 0 else None + + if requisition and requisition["status"] in ["EX", "RJ", "SU"]: + await self.hass.async_add_executor_job(requisitions.remove, requisition["id"]) + requisition = None + + if requisition: + return requisition + + return await self.hass.async_add_executor_job( + create_req( + requisitions.create, + redirect="https://127.0.0.1", + institution_id=institution_id, + reference=reference, + ) + ) + + def _get_client(self, secret_id, secret_key): + if secret_id and secret_key: + return get_client(secret_id=secret_id, secret_key=secret_key) + + async def flow(self, user_input): + errors = {} + + user_input = user_input or {} + + schema = OrderedDict() + schema[vol.Required(const["ACCOUNT_HOLDER"], default=user_input.get(const["ACCOUNT_HOLDER"]))] = cv.string + schema[ + vol.Required( + const["SECRET_ID"], + default=user_input.get(const["SECRET_ID"], secret_id), + ) + ] = cv.string + schema[ + vol.Required( + const["SECRET_KEY"], + default=user_input.get(const["SECRET_KEY"], secret_key), + ) + ] = cv.string + + country_field = const["COUNTRY_FIELD"] + schema[vol.Required(country_field, default=user_input.get(country_field))] = vol.In(const["COUNTRIES"]) + + client = self._get_client( + secret_id=user_input.get(const["SECRET_ID"]), + secret_key=user_input.get(const["SECRET_KEY"]), + ) + + if user_input.get(const["COUNTRY_FIELD"]): + try: + institutions = await self.hass.async_add_executor_job( + client.institutions.by_country, user_input[const["COUNTRY_FIELD"]] + ) + if not user_input.get(const["INSTITUTION_ID"]): + schema[ + vol.Required( + const["INSTITUTION_ID"], + default=user_input.get(const["INSTITUTION_ID"]), + ) + ] = vol.In([institution["id"] for institution in institutions]) + except Exception as exception: + print("country exception", exception) + errors["institution"] = str(exception) + return (vol.Schema(schema), user_input, errors) + + if user_input.get(const["INSTITUTION_ID"]): + try: + requisition = await self.get_requisition( + requisitions=client.requisitions, + institution_id=user_input[const["INSTITUTION_ID"]], + account_holder=user_input[const["ACCOUNT_HOLDER"]], + ) + + print("requisitions", requisition) + if requisition["status"] != "LN": + info = f"Visit the link to activate the connection {requisition['link']}" + if user_input.get(const["INSTITUTION_ID"]): + schema[ + vol.Required( + const["INSTITUTION_ID"], + default=user_input.get(const["INSTITUTION_ID"]), + description=info, + ) + ] = vol.In([institution["id"] for institution in institutions]) + errors["requisition"] = info + return (vol.Schema(schema), user_input, errors) + + if user_input.get(const["INSTITUTION_ID"]): + schema[ + vol.Required( + const["INSTITUTION_ID"], + default=user_input.get(const["INSTITUTION_ID"]), + ) + ] = vol.In([institution["id"] for institution in institutions]) + except Exception as exception: + print("requisition exception", exception) + errors["requisition"] = str(exception) + return (vol.Schema(schema), user_input, errors) + + return (vol.Schema(schema), user_input, errors) + + async def async_step_user(self, user_input={}): + schema, user_input, errors = await self.flow(user_input) + self.async_create_entry(title="nordigen", data=user_input) + + if user_input.get("done"): + print("whoo, done") + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=schema, + ) diff --git a/manifest.json b/custom_components/nordigen/manifest.json similarity index 57% rename from manifest.json rename to custom_components/nordigen/manifest.json index 69c090a..2d65545 100644 --- a/manifest.json +++ b/custom_components/nordigen/manifest.json @@ -1,12 +1,15 @@ { - "domain": "nordigen", "name": "Nordigen for Home Assistant", - "documentation": "https://github.com/dogmatic69/nordigen-homeassistant/wiki", + "version": "v0.5", + "domain": "nordigen", + "integration_type": "hub", + "documentation": "https://github.com/dogmatic69/nordigen-homeassistant/blob/master/README.md", "issue_tracker": "https://github.com/dogmatic69/nordigen-homeassistant/issues", "dependencies": [], "after_dependencies": [], "codeowners": ["@dogmatic69"], - "requirements": ["nordigen-ha-lib==0.3.0"], - "iot_class": "cloud_polling", - "version": "0.3.0" - } \ No newline at end of file + "config_flow": true, + "requirements": ["nordigen-python>=0.2.0b5"], + "quality_scale": "silver", + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/custom_components/nordigen/ng.py b/custom_components/nordigen/ng.py new file mode 100644 index 0000000..4e540fd --- /dev/null +++ b/custom_components/nordigen/ng.py @@ -0,0 +1,140 @@ +from nordigen import wrapper as Client +import requests + + +def get_client(**kwargs): + return Client(**kwargs) + + +def get_reference(enduser_id, institution_id, *args, **kwargs): + return f"{enduser_id}-{institution_id}" + + +def unique_ref(id, account): + for key in ["iban", "bban", "resourceId"]: + val = account.get(key) + if val: + return val + return id + + +def get_requisitions(client, configs, logger, const): + requisitions = [] + try: + requisitions = client.requisitions.list()["results"] + except (requests.exceptions.HTTPError, KeyError) as error: + logger.error("Unable to fetch Nordigen requisitions: %s", error) + + processed = [] + for config in configs: + processed.append( + get_or_create_requisition( + fn_create=client.requisitions.create, + fn_remove=client.requisitions.remove, + fn_info=client.requisitions.by_id, + requisitions=requisitions, + reference=get_reference(**config), + institution_id=config[const["INSTITUTION_ID"]], + logger=logger, + config=config, + ) + ) + + return processed + + +def get_or_create_requisition(fn_create, fn_remove, fn_info, requisitions, reference, institution_id, logger, config): + requisition = matched_requisition(reference, requisitions) + if requisition and requisition.get("status") in ["EX", "SU"]: + fn_remove( + **{ + "id": requisition["id"], + } + ) + + logger.info("Requisition was in failed state, removed :%s", requisition) + requisition = None + + if not requisition: + requisition = fn_create( + **{ + "redirect": "https://127.0.0.1/", + "institution_id": institution_id, + "reference": reference, + } + ) + logger.debug("No requisition found, created :%s", requisition) + + if requisition.get("status") != "LN": + logger.debug("Requisition not linked :%s", requisition) + logger.info("Authenticate and accept connection and restart :%s", requisition["link"]) + + if not requisition.get("details"): + requisition["details"] = { + "id": "N26_NTSBDEB1", + "name": "N26 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "730", + "countries": ["SI"], + "logo": "https://cdn.nordigen.com/ais/N26_NTSBDEB1.png", + } + del requisition["details"]["countries"] + + requisition["config"] = config + return requisition + + +def get_accounts(fn, requisition, logger, ignored): + accounts = [] + for account_id in requisition.get("accounts", []): + if account_id in ignored: + logger.info("Account ignored due to configuration :%s", account_id) + continue + + accounts.append( + get_account( + fn=fn, + id=account_id, + requisition=requisition, + logger=logger, + ) + ) + return [account for account in accounts if account] + + +def get_account(fn, id, requisition, logger): + account = {} + try: + account = fn(id) + account = account.get("account", {}) + except Exception as error: + logger.error("Unable to fetch account details from Nordigen: %s", error) + return + + if not account.get("iban"): + logger.warn("No iban: %s | %s", requisition, account) + + ref = unique_ref(id, account) + + account = { + "id": id, + "unique_ref": ref, + "name": account.get("name"), + "owner": account.get("ownerName"), + "currency": account.get("currency"), + "product": account.get("product"), + "status": account.get("status"), + "bic": account.get("bic"), + "iban": account.get("iban"), + "bban": account.get("bban"), + } + logger.info("Loaded account info for account # :%s", id) + return account + + +def matched_requisition(ref, requisitions): + for requisition in requisitions: + if requisition["reference"] == ref: + return requisition + + return {} diff --git a/custom_components/nordigen/sensor.py b/custom_components/nordigen/sensor.py new file mode 100644 index 0000000..a79320a --- /dev/null +++ b/custom_components/nordigen/sensor.py @@ -0,0 +1,483 @@ +"""Platform for sensor integration.""" +from datetime import datetime, timedelta +import random +import re + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator, UpdateFailed + +from . import const, logger +from .ng import get_accounts + +pattern = re.compile(r"(? bool: + return True + + +class BalanceSensorEntityDescription(SensorEntityDescription): + pass + + +class BalanceSensor(CoordinatorEntity, SensorEntity): + _attr_attribution = ATTRIBUTION + + def __init__( + self, + domain, + icons, + coordinator, + id, + iban, + bban, + unique_ref, + name, + owner, + currency, + product, + status, + bic, + requisition, + balance_type, + config, + device, + ): + self._icons = icons + self._domain = domain + self._balance_type = balance_type + self._id = id + self._iban = iban + self._bban = bban + self._unique_ref = unique_ref + self._name = name + self._owner = owner + self._currency = currency + self._product = product + self._status = status + self._bic = bic + self._requisition = requisition + self._config = config + self._attr_device_info = device + + self.entity_description = BalanceSensorEntityDescription( + key=self.unique_id, + name=self.name, + native_unit_of_measurement=self._currency, + icon=self.icon, + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.MEASUREMENT, + ) + + super().__init__(coordinator) + + @property + def _device(self): + return self._attr_device_info + + @property + def unique_id(self): + return f"{self._unique_ref}-{self.balance_type}" + + @property + def balance_type(self): + return snake(self._balance_type) + + @property + def name(self): + if self._owner and self._name: + return f"{self._owner} {self._name} ({self.balance_type})" + + if self._name: + return f"{self._name} {self._unique_ref} ({self.balance_type})" + + return f"{self._unique_ref} ({self.balance_type})" + + @property + def state(self): + if not self.coordinator.data[self._balance_type]: + return None + return round(float(self.coordinator.data[self._balance_type]), 2) + + @property + def state_attributes(self): + return { + "balance_type": self._balance_type, + "iban": self._iban, + "unique_ref": self._unique_ref, + "name": self._name, + "owner": self._owner, + "product": self._product, + "status": self._status, + "bic": self._bic, + "reference": self._requisition["reference"], + "last_update": datetime.now(), + } + + @property + def native_unit_of_measurement(self): + return self._currency + + @property + def icon(self): + return self._icons.get(self._currency, self._icons.get("default")) + + @property + def available(self) -> bool: + return True diff --git a/custom_components/nordigen/strings.json b/custom_components/nordigen/strings.json new file mode 100644 index 0000000..61e4857 --- /dev/null +++ b/custom_components/nordigen/strings.json @@ -0,0 +1,40 @@ +{ + "title": "Nordigen Integration", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "requisition": "[%key:common::config_flow::error::cannot_connect%]", + "institution": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "title": "Configure Open Banking", + "description": "Enter your Nordigen API credentials. (Can be found @ https://ob.nordigen.com/user-secrets/)", + "data": { + "account_holder": "Account Holder", + "secret_id": "Nordigen Secret Id", + "secret_key": "Nordigen Secret Key", + "country": "Two letter country code supported by Nordigen", + "institution_id": "Institution", + "refresh_rate": "Refresh rate in seconds", + "balance_types": "Balance types to track as sensors", + "ignore_accounts": "Accounts to be ignored" + } + } + } + }, + "options": { + "error": { + "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository." + }, + "step": { + "user": { + "title": "Configure Open Banking" + } + } + } +} \ No newline at end of file diff --git a/custom_components/nordigen/translations/en.json b/custom_components/nordigen/translations/en.json new file mode 100644 index 0000000..61e4857 --- /dev/null +++ b/custom_components/nordigen/translations/en.json @@ -0,0 +1,40 @@ +{ + "title": "Nordigen Integration", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "requisition": "[%key:common::config_flow::error::cannot_connect%]", + "institution": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "title": "Configure Open Banking", + "description": "Enter your Nordigen API credentials. (Can be found @ https://ob.nordigen.com/user-secrets/)", + "data": { + "account_holder": "Account Holder", + "secret_id": "Nordigen Secret Id", + "secret_key": "Nordigen Secret Key", + "country": "Two letter country code supported by Nordigen", + "institution_id": "Institution", + "refresh_rate": "Refresh rate in seconds", + "balance_types": "Balance types to track as sensors", + "ignore_accounts": "Accounts to be ignored" + } + } + } + }, + "options": { + "error": { + "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository." + }, + "step": { + "user": { + "title": "Configure Open Banking" + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json index 2ffc302..ae900e7 100644 --- a/hacs.json +++ b/hacs.json @@ -1,16 +1,14 @@ { "name": "Nordigen", - "render_readme": true, - "domains": ["sensor"], - "iot_class": "Cloud Polling", - "content_in_root": true, + "hacs": "1.28.2", + "homeassistant": "2022.10.0", "country": [ - "AT", "BE", "BG", "HR", "CY", - "CZ", "DK", "EE", "FI", "FR", - "DE", "GR", "HU", "IS", "IE", - "IT", "LV", "LI", "LT", "LU", - "MT", "NL", "NO", "PL", "PT", - "RO", "SK", "SI", "ES", "SE", - "GB" - ] - } \ No newline at end of file + "AT", "BE", "BG", "HR", "CY", + "CZ", "DK", "EE", "FI", "FR", + "DE", "GR", "HU", "IS", "IE", + "IT", "LV", "LI", "LT", "LU", + "MT", "NL", "NO", "PL", "PT", + "RO", "SK", "SI", "ES", "SE", + "GB" + ] +} \ No newline at end of file diff --git a/info.md b/info.md new file mode 100644 index 0000000..d9117cb --- /dev/null +++ b/info.md @@ -0,0 +1,56 @@ +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +_Component to integrate with [nordigen][nordigen]._ + +**This component will set up the following platforms.** + +Platform | Description +-- | -- +`binary_sensor` | Show something `True` or `False`. +`sensor` | Show info from API. +`switch` | Switch something `True` or `False`. + +![example][initiateimg] + +{% if not installed %} +## Installation + +1. Click install. +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Blueprint". + +{% endif %} + + +## Configuration is done in the UI + + + +*** + +[nordigen]: https://github.com/dogmatic69/nordigen-homeassistant +[buymecoffee]: https://www.buymeacoffee.com/dogmatic69 +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[commits-shield]: https://img.shields.io/github/commit-activity/y/dogmatic69/nordigen-homeassistant.svg?style=for-the-badge +[commits]: https://github.com/dogmatic69/nordigen-homeassistant/commits/master +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[discord]: https://discord.gg/Qa5fW2R +[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge +[initiateimg]: pics/initiate.png +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license]: https://github.com/dogmatic69/nordigen-homeassistant/blob/main/LICENSE +[license-shield]: https://img.shields.io/github/license/dogmatic69/nordigen-homeassistant.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/dogmatic69/nordigen-homeassistant.svg?style=for-the-badge +[releases]: https://github.com/dogmatic69/nordigen-homeassistant/releases +[user_profile]: https://github.com/ludeeus diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c18f3b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length=120 +py36=true \ No newline at end of file diff --git a/renovate.json b/renovate.json index f45d8f1..70f2d04 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,15 @@ { - "extends": [ - "config:base" - ] + "extends": ["config:base"], + "semanticCommits": "enabled", + "semanticCommitScope": null, + "commitMessageTopic": "{{depName}}", + "additionalBranchPrefix": "{{baseDir}}-", + "rebaseWhen": "behind-base-branch", + "lockFileMaintenance": { + "enabled": true + }, + "automerge": true, + "prConcurrentLimit": 15, + "prHourlyLimit": 5, + "timezone": "Europe/Stockholm" } diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..67296d7 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +homeassistant \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..71f5999 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component diff --git a/sensor.py b/sensor.py deleted file mode 100644 index 5582ce5..0000000 --- a/sensor.py +++ /dev/null @@ -1,36 +0,0 @@ -from . import LOGGER, CONST, DOMAIN - -from nordigen_lib.sensor import build_sensors - - -async def async_setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the sensor platform via discovery only.""" - if discovery_info is None: - return - - LOGGER.info( - "Nordigen will attempt to configure [%s] accounts", - len(discovery_info["accounts"]), - ) - - entities = [] - for account in discovery_info.get("accounts"): - LOGGER.debug("Registering sensor for account :%s", account) - - entities.extend( - await build_sensors( - hass=hass, - LOGGER=LOGGER, - account=account, - CONST=CONST, - ) - ) - - if not len(entities): - return False - - LOGGER.info("Total of [%s] Nordigen account sensors configured", len(entities)) - LOGGER.debug("entities :%s", entities) - - add_entities(entities) - return True diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6baf202 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,47 @@ +[metadata] +description-file = README.md + +[tool:pytest] +addopts = --cov=custom_components/nordigen --cov-fail-under=100 --cov-report xml --cov-report html --cov-report term-missing --junitxml=pytest-report.xml +asyncio_mode = auto + +[coverage:report] +fail_under = 100 +skip_covered = True + +[coverage:run] +relative_files=True + +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +max_line_length = 120 +max_complexity = 5 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202: No blank lines allowed after function docstring +# W504: line break after binary operator +# D10x: missing doc string +ignore = + E501, + W503, + E203, + D202, + W504, + D100,D101,D102,D103,D104,D107 + +[isort] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 120 +indent = " " +not_skip = __init__.py +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components.nordigen,tests +combine_as_imports = true +no_lines_before = STDLIB,LOCALFOLDER diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ee7a4ba --- /dev/null +++ b/setup.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python + +import setuptools + +application_dependencies = [ + "homeassistant>=2022.10.5", + "voluptuous", + "nordigen-python>=0.2.0b5", + "apiclient", +] +prod_dependencies = [] +test_dependencies = [ + "pytest", + "parameterized", + "pytest-env", + "pytest-cov<=4.0.0", + "pytest-asyncio", + "vcrpy", + "requests-mock", + "pytest-homeassistant-custom-component==0.12.12", +] +lint_dependencies = ["flake8", "flake8-docstrings", "black", "isort"] +docs_dependencies = [] +dev_dependencies = test_dependencies + lint_dependencies + docs_dependencies + ["ipdb"] +publish_dependencies = ["requests", "twine"] + + +with open("README.md", "r") as fh: + long_description = fh.read() + + +with open("VERSION", "r") as buf: + version = buf.read() + + +setuptools.setup( + name="nordigen-homeassistant", + version=version, + description="Home Assistant integration for Nordigen banking API's using nordigen-python", + long_description=long_description, + long_description_content_type="text/markdown", + author="Carl Sutton (dogmatic69)", + author_email="dogmatic69@gmail.com", + license="MIT", + keywords=["nordigen", "banking", "home hassistant", "hassio"], + url="https://github.com/dogmatic69/nordigen-homehassistant", + python_requires=">=3.6", + packages=["custom_components/nordigen"], + classifiers=[ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3.8", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + ], + install_requires=application_dependencies, + extras_require={ + "production": prod_dependencies, + "test": test_dependencies, + "lint": lint_dependencies, + "docs": dev_dependencies, + "dev": dev_dependencies, + "publish": publish_dependencies, + }, + include_package_data=True, + zip_safe=False, +) diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..5591a33 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=dogmatic69_nordigen-homeassistant +sonar.organization=dogmatic69 + +sonar.projectName=nordigen-homeassistant + +sonar.sources=custom_components/nordigen +sonar.tests=tests +sonar.language=python + +sonar.sourceEncoding=UTF-8 + +sonar.python.version=3.10 +sonar.python.coverage.reportPaths=coverage.xml,pytest-report.xml diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..67dee6c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,24 @@ +# Why? + +While tests aren't required to publish a custom component for Home Assistant, they will generally make development easier because good tests will expose when changes you want to make to the component logic will break expected functionality. Home Assistant uses [`pytest`](https://docs.pytest.org/en/latest/) for its tests, and the tests that have been included are modeled after tests that are written for core Home Assistant integrations. These tests pass with 100% coverage (unless something has changed ;) ) and have comments to help you understand the purpose of different parts of the test. + +# Getting Started + +To begin, it is recommended to create a virtual environment to install dependencies: +```bash +python3 -m venv venv +source venv/bin/activate +``` + +You can then install the dependencies that will allow you to run tests: +`pip3 install -r requirements_test.txt.` + +This will install `homeassistant`, `pytest`, and `pytest-homeassistant-custom-component`, a plugin which allows you to leverage helpers that are available in Home Assistant for core integration tests. + +# Useful commands + +Command | Description +------- | ----------- +`pytest tests/` | This will run all tests in `tests/` and tell you how many passed/failed +`pytest --durations=10 --cov-report term-missing --cov=custom_components.nordigen tests` | This tells `pytest` that your target module to test is `custom_components.nordigen` so that it can give you a [code coverage](https://en.wikipedia.org/wiki/Code_coverage) summary, including % of code that was executed and the line numbers of missed executions. +`pytest tests/test_init.py -k test_setup_unload_and_reload_entry` | Runs the `test_setup_unload_and_reload_entry` test function located in `tests/test_init.py` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5762881 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,18 @@ +"""Tests for nordigen integration.""" +from unittest.mock import MagicMock, Mock + +from apiclient.request_strategies import BaseRequestStrategy +from nordigen import wrapper as Client + + +class AsyncMagicMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMagicMock, self).__call__(*args, **kwargs) + + +def test_client( + request_strategy=Mock(spec=BaseRequestStrategy), + secret_id="secret-id", + secret_key="secret-key", +): + return Client(request_strategy=request_strategy, secret_id=secret_id, secret_key=secret_key) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..90e1042 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test nordigen config flow.""" +import unittest +import pytest +from unittest.mock import AsyncMock, MagicMock, call +from . import AsyncMagicMock + +from voluptuous.error import Invalid + +from custom_components.nordigen.config_flow import ( + valid_country, + get_institutions, + create_req, + NordigenConfigFlow, +) + + +class TestHelpers(unittest.TestCase): + def test_valid_country(self): + assert valid_country("SE") == "SE" + assert valid_country("se") == "SE" + assert valid_country("sE") == "SE" + + def test_valid_country_not(self): + with self.assertRaises(Invalid): + valid_country("zz") + + def test_get_institutions(self): + fn = MagicMock() + fn.return_value = "test" + assert get_institutions(fn, "SE")() == "test" + + def test_create_req(self): + fn = MagicMock() + fn.return_value = "test" + assert create_req(fn, a=1, b=2)() == "test" + + +class TestConfigFlowGetRequisition: + @unittest.mock.patch("custom_components.nordigen.config_flow.create_req") + @pytest.mark.asyncio + async def test_get_requisition_ex(self, mocked_create_req): + """Requisition exists but is expired, rejected or similar.""" + mocked_hass = AsyncMagicMock() + mocked_requisitions = AsyncMagicMock() + + inst = NordigenConfigFlow() + inst.hass = mocked_hass + + inst.hass.async_add_executor_job.side_effect = [ + { + "results": [ + {"reference": "test-1", "status": "EX", "id": 321}, + {"reference": "bob-smith-institute-123", "status": "EX", "id": 123}, + ] + }, + None, + {"reference": "bob-smith-institute-123", "status": "CR"}, + ] + + result = await inst.get_requisition( + requisitions=mocked_requisitions, institution_id="institute-123", account_holder="bob-smith" + ) + + mocked_create_req.assert_called_once_with( + mocked_requisitions.create, + redirect="https://127.0.0.1", + institution_id="institute-123", + reference="bob-smith-institute-123", + ) + + assert inst.hass.async_add_executor_job.mock_calls == [ + call(mocked_requisitions.list), + call(mocked_requisitions.remove, 123), + call(mocked_create_req()), + ] + assert result == {"reference": "bob-smith-institute-123", "status": "CR"} + + @unittest.mock.patch("custom_components.nordigen.config_flow.create_req") + @pytest.mark.asyncio + async def test_get_requisition_cr(self, mocked_create_req): + """Requisition exists and is valid.""" + mocked_hass = AsyncMagicMock() + mocked_requisitions = AsyncMagicMock() + + inst = NordigenConfigFlow() + inst.hass = mocked_hass + + inst.hass.async_add_executor_job.side_effect = [ + { + "results": [ + {"reference": "test-1", "status": "CR", "id": 321}, + {"reference": "bob-smith-institute-333", "status": "CR", "id": 123}, + ] + }, + None, + {"reference": "no", "status": "CR"}, + ] + + result = await inst.get_requisition( + requisitions=mocked_requisitions, institution_id="institute-333", account_holder="bob-smith" + ) + + mocked_create_req.assert_not_called() + + assert inst.hass.async_add_executor_job.mock_calls == [ + call(mocked_requisitions.list), + ] + assert result == {"reference": "bob-smith-institute-333", "status": "CR", "id": 123} + + @unittest.mock.patch("custom_components.nordigen.config_flow.create_req") + @pytest.mark.asyncio + async def test_get_requisition_none(self, mocked_create_req): + """Requisition does not exist.""" + mocked_hass = AsyncMagicMock() + mocked_requisitions = AsyncMagicMock() + + inst = NordigenConfigFlow() + inst.hass = mocked_hass + + inst.hass.async_add_executor_job.side_effect = [ + { + "results": [ + {"reference": "test-1", "status": "CR", "id": 321}, + ] + }, + {"reference": "bob-smith-institute-333", "status": "CR", "id": 123}, + ] + + result = await inst.get_requisition( + requisitions=mocked_requisitions, institution_id="institute-333", account_holder="bob-smith" + ) + + mocked_create_req.assert_called_once_with( + mocked_requisitions.create, + redirect="https://127.0.0.1", + institution_id="institute-333", + reference="bob-smith-institute-333", + ) + + assert inst.hass.async_add_executor_job.mock_calls == [ + call(mocked_requisitions.list), + call(mocked_create_req()), + ] + assert result == {"reference": "bob-smith-institute-333", "status": "CR", "id": 123} + + +class TestConfigFlow: + @pytest.mark.asyncio + async def test_init_initial(self): + """Test init.""" + inst = NordigenConfigFlow() + schema, user_input, errors = await inst.flow({}) + + assert errors == {} + assert user_input == {} diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..101a9cb --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,153 @@ +"""Test nordigen setup process.""" +import unittest +import pytest +from unittest.mock import MagicMock +from parameterized import parameterized + +from homeassistant.exceptions import ConfigEntryNotReady +from nordigen.client import AccountClient +from pytest_homeassistant_custom_component.common import MockConfigEntry + +# from custom_components.nordigen import ( +# async_reload_entry, +# async_setup_entry, +# async_unload_entry, +# ) +from custom_components.nordigen import DOMAIN, async_setup, const, get_client, get_config, setup as ha_setup +from custom_components.nordigen.ng import ( + get_account, + get_accounts, + get_or_create_requisition, + get_reference, + get_requisitions, + matched_requisition, + requests, + unique_ref, +) + +MOCK_CONFIG = {} + + +# async def test_setup_unload_and_reload_entry(hass, bypass_get_data): +# # Create a mock entry so we don't have to go through config flow +# config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + +# assert await async_setup_entry(hass, config_entry) +# assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] +# assert type(hass.data[DOMAIN][config_entry.entry_id]) == BlueprintDataUpdateCoordinator + +# # Reload the entry and assert that the data from above is still there +# assert await async_reload_entry(hass, config_entry) is None +# assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] +# assert type(hass.data[DOMAIN][config_entry.entry_id]) == BlueprintDataUpdateCoordinator + +# # Unload the entry and verify that the data has been removed +# assert await async_unload_entry(hass, config_entry) +# assert config_entry.entry_id not in hass.data[DOMAIN] + + +# async def test_setup_entry_exception(hass, error_on_get_data): +# config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + +# with pytest.raises(ConfigEntryNotReady): +# assert await async_setup_entry(hass, config_entry) + + +class TestGetConfig(unittest.TestCase): + def test_not_found(self): + res = get_config([], {}) + + self.assertEqual(None, res) + + def test_first(self): + res = get_config( + [ + {"enduser_id": "user1", "institution_id": "aspsp1"}, + {"enduser_id": "user2", "institution_id": "aspsp2"}, + {"enduser_id": "user3", "institution_id": "aspsp3"}, + ], + {"reference": "user1-aspsp1"}, + ) + + self.assertEqual({"enduser_id": "user1", "institution_id": "aspsp1"}, res) + + def test_last(self): + res = get_config( + [ + {"enduser_id": "user1", "institution_id": "aspsp1"}, + {"enduser_id": "user2", "institution_id": "aspsp2"}, + {"enduser_id": "user3", "institution_id": "aspsp3"}, + ], + {"reference": "user3-aspsp3"}, + ) + + self.assertEqual({"enduser_id": "user3", "institution_id": "aspsp3"}, res) + + +class TestGetClient(unittest.TestCase): + def test_basic(self): + res = get_client( + **{ + "secret_id": "secret1", + "secret_key": "secret2", + } + ) + + self.assertIsInstance(res.account, AccountClient) + + +# class TestSetup(unittest.TestCase): +# def test_not_installed(self): +# ha_setup({}, {}) + + +class TestEntry(unittest.TestCase): + @unittest.mock.patch("custom_components.nordigen.logger") + @unittest.mock.patch("custom_components.nordigen.ng.Client") + def test_not_configured(self, mocked_client, mocked_logger): + res = ha_setup(hass=None, config={}) + mocked_logger.warning.assert_called_with("Nordigen not configured") + + self.assertTrue(res) + + @unittest.mock.patch("custom_components.nordigen.logger") + @unittest.mock.patch("custom_components.nordigen.get_requisitions") + @unittest.mock.patch("custom_components.nordigen.get_client") + def test_entry(self, mocked_get_client, mocked_get_requisitions, mocked_logger): + hass = MagicMock() + client = MagicMock() + + mocked_get_requisitions.return_value = ["requisition"] + mocked_get_client.return_value = client + + config = {"nordigen": {"secret_id": "xxxx", "secret_key": "yyyy", "requisitions": "requisitions"}} + + res = ha_setup(hass=hass, config=config) + + mocked_get_client.assert_called_with(secret_id="xxxx", secret_key="yyyy") + mocked_get_requisitions.assert_called_with( + client=client, + configs="requisitions", + logger=mocked_logger, + const=const, + ) + hass.helpers.discovery.load_platform.assert_called_with( + "sensor", "nordigen", {"requisitions": ["requisition"]}, config + ) + + self.assertTrue(res) + + +# class TestSetup(unittest.TestCase): +# async def test_unload(self): +# pass + +# async def test_reload(self): +# pass + + +class TestAsyncSetup: + @pytest.mark.asyncio + async def test_basics(self): + result = await async_setup(hass=None, config=None) + assert result is True diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..9f8bb2d --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,193 @@ +import unittest +from unittest.mock import MagicMock, patch + +from custom_components.nordigen import setup as ha_setup +from custom_components.nordigen.ng import get_client + + +class TestIntegration(unittest.TestCase): + @patch("custom_components.nordigen.get_client") + def test_new_install(self, mocked_get_client): + hass = MagicMock() + + config = { + "nordigen": { + "secret_id": "xxxx", + "secret_key": "yyyy", + "requisitions": [ + { + "institution_id": "aspsp_123", + "enduser_id": "user_123", + "ignore": [], + } + ], + }, + } + + client = get_client(secret_id="xxxx", secret_key="xxxx") + client.requisitions.get = MagicMock( + side_effect=[ + {"results": []}, # call 1: first call has no requisitions + ] + ) + client.requisitions.post = MagicMock( + side_effect=[ + { + "id": "req-123", + "status": "CR", + "link": "https://example.com/whoohooo", + }, # call 2: initiate requisition + ] + ) + mocked_get_client.return_value = client + + with self.assertWarns(DeprecationWarning): + ha_setup(hass=hass, config=config) + + client.requisitions.post.assert_called_once() + client.requisitions.get.assert_called_once() + + @unittest.mock.patch("custom_components.nordigen.get_client") + def test_existing_install(self, mocked_get_client): + hass = MagicMock() + + clinet_instance = MagicMock() + mocked_get_client.return_value = clinet_instance + + config = { + "nordigen": { + "secret_id": "xxxx", + "secret_key": "yyyy", + "requisitions": [ + { + "institution_id": "aspsp_123", + "enduser_id": "user_123", + "ignore": [ + "resourceId-123", + ], + }, + { + "institution_id": "aspsp_321", + "enduser_id": "user_321", + "ignore": [], + }, + ], + }, + } + + clinet_instance.requisitions.list.side_effect = [ + { + "results": [ + { + "id": "req-123", + "status": "LN", + "reference": "user_123-aspsp_123", + "accounts": [ + "account-1", + "account-2", + "account-3", + ], + }, + { + "id": "req-321", + "status": "LN", + "reference": "user_321-aspsp_321", + "accounts": [ + "account-a", + ], + }, + ] + }, + ] + + clinet_instance.account.details.side_effect = [ + { + "account": { + "iban": "iban-123", + } + }, + { + "account": { + "bban": "bban-123", + } + }, + { + "account": { + "resourceId": "resourceId-123", + } + }, + { + "account": { + "iban": "yee-haa", + } + }, + ] + + ha_setup(hass=hass, config=config) + + clinet_instance.requisitions.create.assert_not_called() + clinet_instance.requisitions.initiate.assert_not_called() + + # TODO: some how assert sensors are loaded too + # clinet_instance.account.details.assert_has_calls( + # [ + # call("account-1"), + # call("account-2"), + # call("account-3"), + # call("account-a"), + # ] + # ) + + details_fixture = { + "id": "N26_NTSBDEB1", + "name": "N26 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "730", + "logo": "https://cdn.nordigen.com/ais/N26_NTSBDEB1.png", + } + hass.helpers.discovery.load_platform.assert_called_once_with( + "sensor", + "nordigen", + { + "requisitions": [ + { + "config": { + "enduser_id": "user_123", + "ignore": ["resourceId-123"], + "institution_id": "aspsp_123", + }, + "accounts": [ + "account-1", + "account-2", + "account-3", + ], + "details": details_fixture, + "id": "req-123", + "reference": "user_123-aspsp_123", + "status": "LN", + }, + { + "config": { + "enduser_id": "user_321", + "ignore": [], + "institution_id": "aspsp_321", + }, + "accounts": ["account-a"], + "details": details_fixture, + "id": "req-321", + "reference": "user_321-aspsp_321", + "status": "LN", + }, + ], + }, + { + "nordigen": { + "requisitions": [ + {"institution_id": "aspsp_123", "enduser_id": "user_123", "ignore": ["resourceId-123"]}, + {"institution_id": "aspsp_321", "enduser_id": "user_321", "ignore": []}, + ], + "secret_id": "xxxx", + "secret_key": "yyyy", + } + }, + ) diff --git a/tests/test_ng.py b/tests/test_ng.py new file mode 100644 index 0000000..7b21ad8 --- /dev/null +++ b/tests/test_ng.py @@ -0,0 +1,317 @@ +import unittest +from unittest.mock import MagicMock + +from parameterized import parameterized + +from custom_components.nordigen.ng import ( + get_account, + get_accounts, + get_or_create_requisition, + get_reference, + get_requisitions, + matched_requisition, + requests, + unique_ref, +) + +details_fixture = { + "id": "N26_NTSBDEB1", + "name": "N26 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "730", + "logo": "https://cdn.nordigen.com/ais/N26_NTSBDEB1.png", +} + + +class TestReference(unittest.TestCase): + def test_basic(self): + res = get_reference("user1", "aspsp1") + self.assertEqual("user1-aspsp1", res) + + @parameterized.expand( + [ + ({"iban": "iban-123"}, "iban-123"), + ({"bban": "bban-123"}, "bban-123"), + ({"resourceId": "resourceId-123"}, "resourceId-123"), + ({"iban": "iban-123", "bban": "bban-123"}, "iban-123"), + ({}, "id-123"), + ] + ) + def test_unique_ref(self, data, expected): + res = unique_ref("id-123", data) + self.assertEqual(expected, res) + + +class TestGetAccount(unittest.TestCase): + def test_request_error(self): + fn = MagicMock() + + fn.side_effect = requests.exceptions.HTTPError + res = get_account(fn, "id", {}, logger=MagicMock()) + self.assertEqual(None, res) + + def test_debug_strange_accounts(self): + fn = MagicMock() + logger = MagicMock() + fn.return_value = {"account": {}} + get_account(fn=fn, id="id", requisition={}, logger=logger) + + logger.warn.assert_called_with("No iban: %s | %s", {}, {}) + + def test_normal(self): + fn = MagicMock() + logger = MagicMock() + fn.return_value = {"account": {"iban": 321}} + res = get_account(fn=fn, id="id", requisition={"id": "req-id"}, logger=logger) + + self.assertEqual(321, res["iban"]) + + +class TestRequisition(unittest.TestCase): + def test_non_match(self): + res = matched_requisition("ref", []) + self.assertEqual({}, res) + + def test_first(self): + res = matched_requisition( + "ref", + [ + {"reference": "ref"}, + {"reference": "fer"}, + {"reference": "erf"}, + ], + ) + self.assertEqual({"reference": "ref"}, res) + + def test_last(self): + res = matched_requisition( + "erf", + [ + {"reference": "ref"}, + {"reference": "fer"}, + {"reference": "erf"}, + ], + ) + self.assertEqual({"reference": "erf"}, res) + + @unittest.mock.patch("custom_components.nordigen.ng.matched_requisition") + def test_get_or_create_requisition_EX(self, mocked_matched_requisition): + logger = MagicMock() + fn_create = MagicMock() + fn_remove = MagicMock() + fn_info = MagicMock() + mocked_matched_requisition.return_value = { + "id": "req-id", + "status": "EX", + } + + fn_create.return_value = { + "id": "foobar-id", + "link": "https://example.com/whatever/1", + } + + res = get_or_create_requisition( + fn_create=fn_create, + fn_remove=fn_remove, + fn_info=fn_info, + requisitions=[], + reference="ref", + institution_id="aspsp", + logger=logger, + config={}, + ) + + fn_remove.assert_called_with( + id="req-id", + ) + + fn_create.assert_called_with( + redirect="https://127.0.0.1/", + reference="ref", + institution_id="aspsp", + ) + + self.assertEqual( + { + "id": "foobar-id", + "link": "https://example.com/whatever/1", + "config": {}, + "details": details_fixture, + }, + res, + ) + + @unittest.mock.patch("custom_components.nordigen.ng.matched_requisition") + def test_get_or_create_requisition_not_exist(self, mocked_matched_requisition): + + logger = MagicMock() + fn_create = MagicMock() + fn_remove = MagicMock() + fn_info = MagicMock() + mocked_matched_requisition.return_value = None + + fn_create.return_value = { + "id": "foobar-id", + "link": "https://example.com/whatever/2", + } + + res = get_or_create_requisition( + fn_create=fn_create, + fn_remove=fn_remove, + fn_info=fn_info, + requisitions=[], + reference="ref", + institution_id="aspsp", + logger=logger, + config={}, + ) + + fn_remove.assert_not_called() + fn_create.assert_called_with( + redirect="https://127.0.0.1/", + reference="ref", + institution_id="aspsp", + ) + + self.assertEqual( + { + "id": "foobar-id", + "link": "https://example.com/whatever/2", + "config": {}, + "details": details_fixture, + }, + res, + ) + + @unittest.mock.patch("custom_components.nordigen.ng.matched_requisition") + def test_get_or_create_requisition_not_linked(self, mocked_matched_requisition): + + logger = MagicMock() + fn_create = MagicMock() + fn_remove = MagicMock() + fn_info = MagicMock() + mocked_matched_requisition.return_value = { + "id": "req-id", + "status": "not-LN", + "link": "https://example.com/whatever/3", + } + + res = get_or_create_requisition( + fn_create=fn_create, + fn_remove=fn_remove, + fn_info=fn_info, + requisitions=[], + reference="ref", + institution_id="aspsp", + logger=logger, + config={}, + ) + + fn_create.assert_not_called() + fn_remove.assert_not_called() + + self.assertEqual( + { + "id": "req-id", + "status": "not-LN", + "link": "https://example.com/whatever/3", + "config": {}, + "details": details_fixture, + }, + res, + ) + + @unittest.mock.patch("custom_components.nordigen.ng.matched_requisition") + def test_get_or_create_requisition_valid(self, mocked_matched_requisition): + + logger = MagicMock() + fn_create = MagicMock() + fn_remove = MagicMock() + fn_info = MagicMock() + mocked_matched_requisition.return_value = { + "id": "req-id", + "status": "LN", + } + + res = get_or_create_requisition( + fn_create=fn_create, + fn_remove=fn_remove, + fn_info=fn_info, + requisitions=[], + reference="ref", + institution_id="aspsp", + logger=logger, + config={}, + ) + + fn_create.assert_not_called() + fn_remove.assert_not_called() + + self.assertEqual( + { + "id": "req-id", + "status": "LN", + "config": {}, + "details": details_fixture, + }, + res, + ) + + +class TestGetAccounts(unittest.TestCase): + def test_api_exception(self): + client = MagicMock() + logger = MagicMock() + + error = requests.exceptions.HTTPError() + client.requisitions.list.side_effect = error + + res = get_requisitions(client=client, configs={}, logger=logger, const={}) + + self.assertEqual([], res) + logger.error.assert_called_with("Unable to fetch Nordigen requisitions: %s", error) + + def test_key_error(self): + fn = MagicMock() + client = MagicMock() + logger = MagicMock() + + client.requisitions.list.return_value = {} + + res = get_accounts(fn=fn, requisition={}, logger=logger, ignored=[]) + + self.assertEqual([], res) + + def test_ignored(self): + fn = MagicMock() + logger = MagicMock() + + get_accounts(fn=fn, requisition={"accounts": [123]}, logger=logger, ignored=[123]) + + logger.info.assert_called_with("Account ignored due to configuration :%s", 123) + + @unittest.mock.patch("custom_components.nordigen.ng.get_account") + def test_works(self, mocked_get_account): + fn = MagicMock() + + logger = MagicMock() + + mocked_get_account.side_effect = [ + {"foobar": "account-1"}, + {"foobar": "account-2"}, + {"foobar": "account-3"}, + ] + + requisition = { + "id": "req-1", + "accounts": [1, 2], + "config": {"ignore_accounts": []}, + } + res = get_accounts(fn=fn, requisition=requisition, logger=logger, ignored=[]) + self.assertEqual( + [ + {"foobar": "account-1"}, + {"foobar": "account-2"}, + ], + res, + ) diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..915caa3 --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,865 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, call + +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.update_coordinator import UpdateFailed +import pytest + +from custom_components.nordigen.sensor import ( + BalanceSensor, + RequisitionSensor, + async_setup_platform, + balance_update, + build_account_sensors, + build_coordinator, + build_requisition_sensor, + build_sensors, + random_balance, + requisition_update, +) +from . import AsyncMagicMock + +case = unittest.TestCase() + +device_fixture = { + "manufacturer": "Nordigen", + "name": "N26 Bank", + "identifiers": {("domain", "req-id")}, + "model": "v2", + "configuration_url": "https://ob.nordigen.com/api/docs", + "entry_type": DeviceEntryType.SERVICE, +} + + +class TestSensorRandom(unittest.TestCase): + def test_basic(self): + res = random_balance() + self.assertTrue(res["balances"][0]["balanceAmount"]["amount"] > 0) + + +class TestBuildCoordinator(unittest.TestCase): + def test_basic(self): + hass = MagicMock() + logger = MagicMock() + updater = MagicMock() + interval = MagicMock() + + res = build_coordinator(hass=hass, logger=logger, updater=updater, interval=interval, reference="ref") + + self.assertEqual(res.hass, hass) + self.assertEqual(res.logger, logger) + self.assertEqual(res.update_method, updater) + self.assertEqual(res.update_interval, interval) + self.assertEqual(res.name, "nordigen-balance-ref") + + def test_listners(self): + hass = MagicMock() + logger = MagicMock() + updater = MagicMock() + interval = MagicMock() + + res = build_coordinator(hass=hass, logger=logger, updater=updater, interval=interval, reference="ref") + + self.assertEqual({}, res._listeners) + + +class TestRequisitionUpdate: + @pytest.mark.asyncio + async def test_return(self): + executor = AsyncMagicMock() + executor.return_value = {"id": "req-id"} + + fn = MagicMock() + logger = MagicMock() + res = requisition_update(logger=logger, async_executor=executor, fn=fn, requisition_id="id") + res = await res() + + case.assertEqual( + res, + {"id": "req-id"}, + ) + + @pytest.mark.asyncio + async def test_exception(self): + executor = AsyncMagicMock() + executor.side_effect = Exception("whoops") + + balance = MagicMock() + logger = MagicMock() + res = requisition_update(logger=logger, async_executor=executor, fn=balance, requisition_id="id") + + with case.assertRaises(UpdateFailed): + await res() + + +class TestBalanceUpdate: + @pytest.mark.asyncio + async def test_return(self): + executor = AsyncMagicMock() + executor.return_value = { + "balances": [ + { + "balanceAmount": { + "amount": 123, + "currency": "SEK", + }, + "balanceType": "interimAvailable", + "creditLimitIncluded": True, + }, + { + "balanceAmount": { + "amount": 321, + "currency": "SEK", + }, + "balanceType": "interimBooked", + }, + ] + } + + fn = MagicMock() + logger = MagicMock() + res = balance_update(logger=logger, async_executor=executor, fn=fn, account_id="id") + res = await res() + + case.assertEqual( + res, + { + "closingBooked": None, + "expected": None, + "openingBooked": None, + "forwardAvailable": None, + "nonInvoiced": None, + "interimAvailable": 123, + "interimBooked": 321, + }, + ) + + @pytest.mark.asyncio + async def test_exception(self): + executor = AsyncMagicMock() + executor.side_effect = Exception("whoops") + + balance = MagicMock() + logger = MagicMock() + res = balance_update(logger=logger, async_executor=executor, fn=balance, account_id="id") + + with case.assertRaises(UpdateFailed): + await res() + + +class TestBuildSensors: + @unittest.mock.patch("custom_components.nordigen.sensor.build_requisition_sensor") + @unittest.mock.patch("custom_components.nordigen.sensor.build_account_sensors") + @pytest.mark.asyncio + async def test_build_sensors_unconfirmed(self, mocked_build_account_sensors, mocked_build_requisition_sensor): + args = { + "hass": "hass", + "logger": "logger", + "account": {"requires_auth": True}, + "const": "const", + "debug": "debug", + } + await build_sensors(**args) + args["requisition"] = args["account"] + del args["account"] + + mocked_build_account_sensors.assert_not_called() + mocked_build_requisition_sensor.assert_called_with(**args) + + +class TestBuildAccountSensors: + def build_sensors_helper(self, account, const, debug=False): + hass = MagicMock() + logger = MagicMock() + + return dict(hass=hass, logger=logger, account=account, const=const, debug=debug, device="device-123") + + @unittest.mock.patch("custom_components.nordigen.sensor.random_balance") + @unittest.mock.patch("custom_components.nordigen.sensor.build_coordinator") + @unittest.mock.patch("custom_components.nordigen.sensor.balance_update") + @pytest.mark.asyncio + async def test_balance_debug(self, mocked_balance_update, mocked_build_coordinator, mocked_random_balance): + account = { + "config": { + "refresh_rate": 1, + "disable": False, + "balance_types": [], + }, + "id": "foobar-id", + "iban": "iban", + "bban": "bban", + "unique_ref": "unique_ref", + "name": "name", + "owner": "owner", + "currency": "currency", + "product": "product", + "status": "status", + "bic": "bic", + "requisition": { + "id": "xyz-123", + "details": { + "id": "req-id", + "name": "req-name", + }, + }, + } + const = { + "REFRESH_RATE": "refresh_rate", + "BALANCE_TYPES": "balance_types", + "DOMAIN": "domain", + "ICON": {"FOO": "foo"}, + } + + mocked_balance_coordinator = MagicMock() + mocked_build_coordinator.return_value = mocked_balance_coordinator + + mocked_balance_coordinator.async_config_entry_first_refresh = AsyncMock() + + args = self.build_sensors_helper(account=account, const=const, debug=True) + await build_account_sensors(**args) + + mocked_balance_update.assert_called_with( + logger=args["logger"], + async_executor=args["hass"].async_add_executor_job, + fn=mocked_random_balance, + account_id="foobar-id", + ) + + @unittest.mock.patch("custom_components.nordigen.sensor.build_coordinator") + @unittest.mock.patch("custom_components.nordigen.sensor.balance_update") + @pytest.mark.asyncio + async def test_balance(self, mocked_balance_update, mocked_build_coordinator): + account = { + "config": { + "refresh_rate": 1, + "disable": False, + }, + "id": "foobar-id", + "iban": "iban", + "bban": "bban", + "unique_ref": "unique_ref", + "name": "name", + "owner": "owner", + "currency": "currency", + "product": "product", + "status": "status", + "bic": "bic", + "requisition": { + "id": "xyz-123", + "details": { + "id": "req-id", + "name": "req-name", + }, + }, + } + const = { + "DOMAIN": "domain", + "REFRESH_RATE": "refresh_rate", + "ICON": {"FOO": "foo"}, + "BALANCE_TYPES": "balance_types", + } + + mocked_balance_coordinator = MagicMock() + mocked_build_coordinator.return_value = mocked_balance_coordinator + + mocked_balance_coordinator.async_config_entry_first_refresh = AsyncMock() + + args = self.build_sensors_helper(account=account, const=const) + await build_account_sensors(**args) + + mocked_balance_update.assert_called_with( + logger=args["logger"], + async_executor=args["hass"].async_add_executor_job, + fn=args["hass"].data["domain"]["client"].account.balances, + account_id="foobar-id", + ) + + @unittest.mock.patch("custom_components.nordigen.sensor.BalanceSensor") + @unittest.mock.patch("custom_components.nordigen.sensor.random_balance") + @unittest.mock.patch("custom_components.nordigen.sensor.build_coordinator") + @unittest.mock.patch("custom_components.nordigen.sensor.timedelta") + @unittest.mock.patch("custom_components.nordigen.sensor.balance_update") + @pytest.mark.asyncio + async def test_available_entities( + self, + mocked_balance_update, + mocked_timedelta, + mocked_build_coordinator, + mocked_random_balance, + mocked_nordigen_balance_sensor, + ): + account = { + "config": { + "refresh_rate": 1, + "balance_types": ["interimAvailable"], + }, + "id": "foobar-id", + } + const = { + "ICON": { + "FOO": "icon_foo", + }, + "DOMAIN": "domain", + "REFRESH_RATE": "refresh_rate", + "BALANCE_TYPES": "balance_types", + } + + mocked_balance_coordinator = MagicMock() + mocked_build_coordinator.return_value = mocked_balance_coordinator + + mocked_balance_coordinator.async_config_entry_first_refresh = AsyncMock() + + args = self.build_sensors_helper(account=account, const=const) + res = await build_account_sensors(**args) + + assert 1 == len(res) + mocked_nordigen_balance_sensor.assert_called_with( + **{ + "id": "foobar-id", + "balance_type": "interimAvailable", + "config": {"refresh_rate": 1, "balance_types": ["interimAvailable"]}, + "coordinator": mocked_balance_coordinator, + "domain": "domain", + "device": "device-123", + "icons": { + "FOO": "icon_foo", + }, + } + ) + + @unittest.mock.patch("custom_components.nordigen.sensor.BalanceSensor") + @unittest.mock.patch("custom_components.nordigen.sensor.random_balance") + @unittest.mock.patch("custom_components.nordigen.sensor.build_coordinator") + @unittest.mock.patch("custom_components.nordigen.sensor.timedelta") + @unittest.mock.patch("custom_components.nordigen.sensor.balance_update") + @pytest.mark.asyncio + async def test_booked_entities( + self, + mocked_balance_update, + mocked_timedelta, + mocked_build_coordinator, + mocked_random_balance, + mocked_nordigen_balance_sensor, + ): + account = { + "id": "foobar-id", + "config": { + "refresh_rate": 1, + "balance_types": ["interimBooked"], + }, + } + const = { + "ICON": {}, + "DOMAIN": "domain", + "REFRESH_RATE": "refresh_rate", + "BALANCE_TYPES": "balance_types", + } + + mocked_balance_coordinator = MagicMock() + mocked_build_coordinator.return_value = mocked_balance_coordinator + + mocked_balance_coordinator.async_config_entry_first_refresh = AsyncMock() + + args = self.build_sensors_helper(account=account, const=const) + res = await build_account_sensors(**args) + + assert 1 == len(res) + mocked_nordigen_balance_sensor.assert_called_with( + **{ + "id": "foobar-id", + "balance_type": "interimBooked", + "config": {"refresh_rate": 1, "balance_types": ["interimBooked"]}, + "coordinator": mocked_balance_coordinator, + "domain": "domain", + "device": "device-123", + "icons": {}, + } + ) + + +class TestSensors(unittest.TestCase): + data = { + "coordinator": MagicMock(), + "id": "account_id", + "domain": "domain", + "device": device_fixture, + "balance_type": "interimWhatever", + "iban": "iban", + "bban": "bban", + "unique_ref": "unique_ref", + "name": "name", + "owner": "owner", + "currency": "currency", + "product": "product", + "status": "status", + "bic": "bic", + "requisition": { + "id": "req-id", + "enduser_id": "req-user-id", + "reference": "req-ref", + "details": { + "id": "N29_NTSBDEB1", + "name": "N29 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "730", + "logo": "https://cdn.nordigen.com/ais/N26_NTSBDEB1.png", + }, + }, + "config": "config", + "icons": { + "foobar": "something", + "default": "something-else", + }, + } + + def test_init(self): + sensor = BalanceSensor(**self.data) + for k in self.data: + if k in ["coordinator"]: + continue + self.assertEqual(getattr(sensor, f"_{k}"), self.data[k]) + + def test_device_info(self): + sensor = BalanceSensor(**self.data) + + self.assertEqual( + device_fixture, + sensor.device_info, + ) + + def test_unique_id(self): + sensor = BalanceSensor(**self.data) + + self.assertEqual("unique_ref-interim_whatever", sensor.unique_id) + + def test_balance_type(self): + sensor = BalanceSensor(**self.data) + + self.assertEqual("interim_whatever", sensor.balance_type) + + def test_name_owner_and_name(self): + sensor = BalanceSensor(**self.data) + + self.assertEqual("owner name (interim_whatever)", sensor.name) + + def test_name_no_owner_but_has_name(self): + sensor = BalanceSensor(**{**self.data, "owner": None}) + + self.assertEqual("name unique_ref (interim_whatever)", sensor.name) + + def test_name_no_owner_or_name(self): + sensor = BalanceSensor(**{**self.data, "owner": None, "name": None}) + + self.assertEqual("unique_ref (interim_whatever)", sensor.name) + + def test_state(self): + ret = {"interimWhatever": "123.990"} + self.data["coordinator"].data.__getitem__.side_effect = ret.__getitem__ + + sensor = BalanceSensor(**self.data) + + self.assertEqual(123.99, sensor.state) + + def test_unused_balance_type_state(self): + ret = {"interimWhatever": None} + self.data["coordinator"].data.__getitem__.side_effect = ret.__getitem__ + + sensor = BalanceSensor(**self.data) + + self.assertEqual(None, sensor.state) + + def test_unit_of_measurement(self): + sensor = BalanceSensor(**self.data) + + self.assertEqual("currency", sensor.unit_of_measurement) + + def test_icon_default(self): + sensor = BalanceSensor(**self.data) + + self.assertEqual("something-else", sensor.icon) + + def test_icon_custom(self): + data = dict(self.data) + data["currency"] = "foobar" + sensor = BalanceSensor(**data) + + self.assertEqual("something", sensor.icon) + + def test_available_true(self): + data = dict(self.data) + data["status"] = "enabled" + sensor = BalanceSensor(**data) + + self.assertEqual(True, sensor.available) + + @unittest.mock.patch("custom_components.nordigen.sensor.datetime") + def test_state_attributes(self, mocked_datatime): + mocked_datatime.now.return_value = "last_update" + sensor = BalanceSensor(**self.data) + + self.assertEqual( + { + "balance_type": "interimWhatever", + "iban": "iban", + "unique_ref": "unique_ref", + "name": "name", + "owner": "owner", + "product": "product", + "status": "status", + "bic": "bic", + "reference": "req-ref", + "last_update": "last_update", + }, + sensor.state_attributes, + ) + + +class TestRequisitionSensor(unittest.TestCase): + mocked_client = MagicMock() + mocked_logger = MagicMock() + data = { + "domain": "domain", + "coordinator": MagicMock(), + "id": "req_id", + "enduser_id": "enduser_id", + "reference": "reference", + "link": "link", + "icons": { + "auth": "something", + "default": "something-else", + }, + "config": "config", + "client": mocked_client, + "logger": mocked_logger, + "ignored_accounts": ["ignore_accounts"], + "const": {}, + "debug": "debug", + "details": { + "id": "N21_NTSBDEB1", + "name": "N21 Bank", + }, + "device": device_fixture, + } + + def test_unconfirmed_device_info(self): + sensor = RequisitionSensor(**self.data) + self.assertEqual( + device_fixture, + sensor.device_info, + ) + + @unittest.mock.patch("custom_components.nordigen.sensor.get_accounts") + def test_job(self, mocked_get_accounts): + sensor = RequisitionSensor(**self.data) + + res = sensor.do_job(foo="bar", fizz="buzz") + res() + + mocked_get_accounts.assert_called_with(foo="bar", fizz="buzz") + + def test_unique_id(self): + sensor = RequisitionSensor(**self.data) + + self.assertEqual("reference", sensor.unique_id) + + def test_unconfirmed_name(self): + sensor = RequisitionSensor(**self.data) + + self.assertEqual("reference", sensor.name) + + def test_state_on(self): + mocked_coordinator = MagicMock() + mocked_coordinator.data = {"status": "LN"} + + sensor = RequisitionSensor(**{**self.data, "coordinator": mocked_coordinator}) + + self.assertEqual(True, sensor.state) + + def test_state_off(self): + mocked_coordinator = MagicMock() + mocked_coordinator.data = {"status": "Not LN"} + + sensor = RequisitionSensor(**{**self.data, "coordinator": mocked_coordinator}) + + self.assertEqual(False, sensor.state) + + def test_unconfirmed_icon(self): + sensor = RequisitionSensor(**self.data) + + self.assertEqual("something", sensor.icon) + + def test_unconfirmed_available_true(self): + sensor = RequisitionSensor(**self.data) + + self.assertEqual(True, sensor.available) + + @unittest.mock.patch("custom_components.nordigen.sensor.datetime") + def test_state_attributes_not_linked(self, mocked_datatime): + mocked_datatime.now.return_value = "last_update" + mocked_coordinator = MagicMock() + mocked_coordinator.data = {"accounts": ["account-1", "account-2"], "status": "Foo"} + sensor = RequisitionSensor(**{**self.data, "coordinator": mocked_coordinator}) + + sensor.hass = MagicMock() + + self.assertEqual( + { + "link": "link", + "info": ( + "Authenticate to your bank with this link. This sensor will " + "monitor the requisition every few minutes and update once " + "authenticated. Once authenticated this sensor will be replaced " + "with the actual account sensor. If you will not authenticate " + "this service consider removing the config entry." + ), + "accounts": ["account-1", "account-2"], + "last_update": "last_update", + "status": "Foo", + }, + sensor.state_attributes, + ) + + @unittest.mock.patch("custom_components.nordigen.sensor.build_account_sensors") + @unittest.mock.patch("custom_components.nordigen.sensor.datetime") + def test_unconfirmed_state_attributes_linked(self, mocked_datatime, mocked_build_account_sensors): + mocked_datatime.now.return_value = "last_update" + mocked_build_account_sensors.return_value = [] + mocked_coordinator = MagicMock() + mocked_coordinator.data = {"accounts": ["account-1", "account-2"], "status": "LN"} + sensor = RequisitionSensor(**{**self.data, "coordinator": mocked_coordinator}) + sensor.hass = MagicMock() + sensor._setup_account_sensors = MagicMock() + + self.assertEqual( + { + "accounts": ["account-1", "account-2"], + "last_update": "last_update", + "status": "LN", + }, + sensor.state_attributes, + ) + + +class TestAccountSensorSetup: + mocked_client = AsyncMagicMock() + mocked_logger = MagicMock() + data = { + "domain": "foobar", + "coordinator": AsyncMagicMock(), + "id": "account_id", + "enduser_id": "enduser_id", + "reference": "reference", + "link": "link", + "icons": { + "auth": "something", + "default": "something-else", + }, + "config": "config", + "client": mocked_client, + "logger": mocked_logger, + "ignored_accounts": ["ignore_accounts"], + "const": {}, + "debug": "debug", + "details": { + "id": "N16_NTSBDEB1", + "name": "N16 Bank", + }, + "device": device_fixture, + } + + @unittest.mock.patch("custom_components.nordigen.sensor.build_account_sensors") + @unittest.mock.patch("custom_components.nordigen.sensor.datetime") + @pytest.mark.asyncio + async def test_setup_account_sensors_new(self, mocked_datatime, mocked_build_account_sensors): + mocked_datatime.now.return_value = "last_update" + mocked_build_account_sensors.return_value = [ + "account-sensor-1", + ] + mocked_coordinator = AsyncMagicMock() + mocked_coordinator.data = {"accounts": ["account-1", "account-2"], "status": "LN"} + sensor = RequisitionSensor(**{**self.data, "coordinator": mocked_coordinator}) + sensor.hass = AsyncMagicMock() + sensor.platform = AsyncMagicMock() + sensor._account_sensors = {"zzz": True} + + mocked_client = AsyncMagicMock() + sensor.hass.async_add_executor_job.return_value = [ + { + "balance_type": "whatever", + "iban": "iban", + "unique_ref": "unique_ref", + "name": "name", + "owner": "owner", + "product": "product", + "status": "status", + "bic": "bic", + "enduser_id": "req-user-id", + "reference": "req-ref", + "last_update": "last_update", + } + ] + await sensor._setup_account_sensors(client=mocked_client, accounts=["account-1"], ignored=[]) + + build_call = { + "account": { + "balance_type": "whatever", + "iban": "iban", + "unique_ref": "unique_ref", + "name": "name", + "owner": "owner", + "product": "product", + "status": "status", + "bic": "bic", + "enduser_id": "req-user-id", + "reference": "req-ref", + "last_update": "last_update", + "config": "config", + "requisition": { + "details": {"id": "N16_NTSBDEB1", "name": "N16 Bank"}, + "id": "account_id", + "reference": "reference", + }, + }, + "const": {}, + "debug": "debug", + "hass": sensor.hass, + "logger": self.mocked_logger, + "device": device_fixture, + } + mocked_build_account_sensors.assert_called_once_with(**build_call) + sensor.platform.async_add_entities.assert_called_once_with(["account-sensor-1"]) + + @unittest.mock.patch("custom_components.nordigen.sensor.get_accounts") + @unittest.mock.patch("custom_components.nordigen.sensor.build_account_sensors") + @unittest.mock.patch("custom_components.nordigen.sensor.datetime") + @pytest.mark.asyncio + async def test_setup_account_sensors_existing( + self, mocked_datatime, mocked_build_account_sensors, mocked_get_accounts + ): + mocked_datatime.now.return_value = "last_update" + mocked_build_account_sensors.return_value = [ + "account-sensor-1", + ] + mocked_coordinator = AsyncMagicMock() + mocked_coordinator.data = {"accounts": ["account-1", "account-2"], "status": "LN"} + sensor = RequisitionSensor(**{**self.data, "coordinator": mocked_coordinator}) + sensor.hass = AsyncMagicMock() + sensor.platform = AsyncMagicMock() + + sensor._account_sensors = {"zzz": True} + + sensor.hass.async_add_executor_job.return_value = [ + { + "balance_type": "whatever", + "iban": "iban", + "unique_ref": "zzz", + "name": "name", + "owner": "owner", + "product": "product", + "status": "status", + "bic": "bic", + "enduser_id": "req-user-id", + "reference": "req-ref", + "last_update": "last_update", + } + ] + mocked_client = AsyncMagicMock() + await sensor._setup_account_sensors(client=mocked_client, accounts=["account-1"], ignored=[]) + + mocked_build_account_sensors.assert_not_called() + sensor.platform.async_add_entities.assert_not_called() + + +class TestBuildUnconfirmedSensor: + @unittest.mock.patch("custom_components.nordigen.sensor.timedelta") + @unittest.mock.patch("custom_components.nordigen.sensor.build_coordinator") + @pytest.mark.asyncio + async def test_build_requisition_sensor(self, mocked_build_coordinator, mocked_timedelta): + hass = MagicMock() + logger = MagicMock() + requisition = { + "id": "req-id", + "enduser_id": "user-123", + "reference": "ref-123", + "link": "https://whatever.com", + "config": { + "ignore_accounts": [], + }, + "details": { + "id": "N25_NTSBDEB1", + "name": "N25 Bank", + }, + } + + const = { + "DOMAIN": "foo", + "ICON": {}, + "IGNORE_ACCOUNTS": "ignore_accounts", + } + + mocked_coordinator = MagicMock() + mocked_coordinator.async_config_entry_first_refresh = AsyncMagicMock() + mocked_build_coordinator.return_value = mocked_coordinator + + sensors = await build_requisition_sensor(hass, logger, requisition, const, False) + + case.assertEqual(1, len(sensors)) + + sensor = sensors[0] + assert isinstance(sensor, RequisitionSensor) + assert sensor.name == "ref-123" + + mocked_timedelta.assert_called_with(seconds=15) + + +class TestAsyncSetupPlatform: + @unittest.mock.patch("custom_components.nordigen.sensor.logger") + @pytest.mark.asyncio + async def test_no_discovery_info(self, mocked_logger): + await async_setup_platform(hass=None, config=None, add_entities=None, discovery_info=None) + mocked_logger.info.assert_not_called() + + @unittest.mock.patch("custom_components.nordigen.sensor.logger") + @pytest.mark.asyncio + async def test_no_requisitions(self, mocked_logger): + result = await async_setup_platform( + hass=None, + config=None, + add_entities=None, + discovery_info={ + "requisitions": [], + }, + ) + + mocked_logger.info.assert_called_with("Nordigen will attempt to configure [%s] requisitions", 0) + mocked_logger.debug.assert_not_called() + + assert result is False + + @unittest.mock.patch("custom_components.nordigen.sensor.build_requisition_sensor") + @unittest.mock.patch("custom_components.nordigen.sensor.logger") + @pytest.mark.asyncio + async def test_basic(self, mocked_logger, mocked_build): + add_mock = MagicMock() + + mocked_build.return_value = ["req"] + await async_setup_platform( + hass="hass", + config={"debug": "debug-123"}, + add_entities=add_mock, + discovery_info={ + "requisitions": [ + "req-1", + "req-2", + ], + }, + ) + + mocked_logger.info.assert_has_calls( + [ + call("Nordigen will attempt to configure [%s] requisitions", 2), + call("Total of [%s] Nordigen account sensors configured", 2), + ], + ) + + add_mock.assert_called_once_with(["req", "req"])