diff --git a/pfbudget/core/manager.py b/pfbudget/core/manager.py index 22beb05..b5145d4 100644 --- a/pfbudget/core/manager.py +++ b/pfbudget/core/manager.py @@ -1,6 +1,8 @@ import csv +import dotenv from pathlib import Path import pickle +import os import webbrowser from pfbudget.common.types import Operation @@ -25,9 +27,13 @@ from pfbudget.db.model import ( Transaction, TransactionCategory, ) +from pfbudget.extract.credentials import Credentials +from pfbudget.extract.extract import Extract from pfbudget.extract.psd2 import PSD2Client from pfbudget.extract.parsers import parse_data +dotenv.load_dotenv() + class Manager: def __init__(self, db: str, verbosity: int = 0): @@ -72,16 +78,16 @@ class Manager: session.add(sorted(transactions)) case Operation.Download: - client = PSD2Client() + client = Manager.nordigen_client() with self.db.session() as session: if len(params[3]) == 0: - client.banks = session.get(Bank, Bank.nordigen) + banks = session.get(Bank, Bank.nordigen) else: - client.banks = session.get(Bank, Bank.name, params[3]) + banks = session.get(Bank, Bank.name, params[3]) session.expunge_all() client.start = params[0] client.end = params[1] - transactions = client.parse() + transactions = client.extract(banks) # dry-run if not params[2]: @@ -116,15 +122,15 @@ class Manager: session.remove_by_name(Nordigen, params) case Operation.Token: - PSD2Client().token() + Manager.nordigen_client().generate_token() case Operation.RequisitionId: - link, _ = PSD2Client().requisition(params[0], params[1]) + link, _ = Manager.nordigen_client().requisition(params[0], params[1]) print(f"Opening {link} to request access to {params[0]}") webbrowser.open(link) case Operation.PSD2CountryBanks: - banks = PSD2Client().country_banks(params[0]) + banks = Manager.nordigen_client().country_banks(params[0]) print(banks) case ( @@ -411,3 +417,12 @@ class Manager: @db.setter def db(self, url: str): self._db = url + + @staticmethod + def nordigen_client() -> Extract: + credentials = Credentials( + os.environ.get("SECRET_ID"), + os.environ.get("SECRET_KEY"), + os.environ.get("TOKEN"), + ) + return PSD2Client(credentials) diff --git a/pfbudget/extract/credentials.py b/pfbudget/extract/credentials.py new file mode 100644 index 0000000..c69d8ac --- /dev/null +++ b/pfbudget/extract/credentials.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class Credentials: + id: str + key: str + token: str = "" + + def valid(self) -> bool: + return self.id and self.key diff --git a/pfbudget/extract/exceptions.py b/pfbudget/extract/exceptions.py new file mode 100644 index 0000000..cb21212 --- /dev/null +++ b/pfbudget/extract/exceptions.py @@ -0,0 +1,10 @@ +class ExtractError(Exception): + pass + + +class BankError(ExtractError): + pass + + +class CredentialsError(ExtractError): + pass diff --git a/pfbudget/extract/extract.py b/pfbudget/extract/extract.py index 0f8dd3d..dbde1de 100644 --- a/pfbudget/extract/extract.py +++ b/pfbudget/extract/extract.py @@ -5,5 +5,5 @@ from pfbudget.db.model import Transaction class Extract(ABC): @abstractmethod - def parse(self) -> list[Transaction]: + def extract(self) -> list[Transaction]: return NotImplementedError diff --git a/pfbudget/extract/psd2.py b/pfbudget/extract/psd2.py index f1ac23a..f202991 100644 --- a/pfbudget/extract/psd2.py +++ b/pfbudget/extract/psd2.py @@ -1,75 +1,55 @@ import datetime as dt -import dotenv import json import nordigen -import os import requests import time import uuid +from typing import Sequence + import pfbudget.db.model as t from pfbudget.utils.converters import convert +from .credentials import Credentials +from .exceptions import BankError, CredentialsError from .extract import Extract -dotenv.load_dotenv() - class PSD2Client(Extract): redirect_url = "https://murta.dev" - def __init__(self): + def __init__(self, credentials: Credentials): super().__init__() - if not (key := os.environ.get("SECRET_KEY")) or not ( - id := os.environ.get("SECRET_ID") - ): - raise + if not credentials.valid(): + raise CredentialsError self._client = nordigen.NordigenClient( - secret_key=key, - secret_id=id, + secret_key=credentials.key, secret_id=credentials.id, timeout=5 ) - self._client.token = self.__token() + if credentials.token: + self._token = credentials.token + self._start = dt.date.min self._end = dt.date.max - def parse(self) -> list[t.BankTransaction]: + def extract(self, banks: Sequence[t.Bank]) -> list[t.BankTransaction]: transactions = [] - assert len(self._banks) > 0 + if not banks or any(not b.nordigen for b in banks): + raise BankError - for bank in self._banks: - print(f"Downloading from {bank}...") - requisition = self.client.requisition.get_requisition_by_id( - bank.nordigen.requisition_id - ) + for bank in banks: + downloaded = None + try: + print(f"Downloading from {bank}...") + downloaded = self.download(bank.nordigen.requisition_id) + except requests.HTTPError as e: + print(f"There was an issue downloading from {bank.name} -> {e}") + continue - print(requisition) - for acc in requisition["accounts"]: - account = self._client.account_api(acc) - - retries = 0 - downloaded = {} - while retries < 3: - try: - downloaded = account.get_transactions() - break - except requests.ReadTimeout: - retries += 1 - print(f"Request #{retries} timed-out, retrying in 1s") - time.sleep(1) - except requests.HTTPError as e: - retries += 1 - print(f"Request #{retries} failed with {e}, retrying in 1s") - time.sleep(1) - - if not downloaded: - print(f"Couldn't download transactions for {account}") - continue - - with open("json/" + bank.name + ".json", "w") as f: - json.dump(downloaded, f) + if downloaded: + self.dump(bank, downloaded) converted = [ convert(t, bank) for t in downloaded["transactions"]["booked"] @@ -81,10 +61,40 @@ class PSD2Client(Extract): return sorted(transactions) - def token(self): - token = self._client.generate_token() - print(f"New access token: {token}") - return token + def download(self, requisition_id): + requisition = self._client.requisition.get_requisition_by_id(requisition_id) + print(requisition) + + transactions = {} + for acc in requisition["accounts"]: + account = self._client.account_api(acc) + + retries = 0 + while retries < 3: + try: + downloaded = account.get_transactions() + break + except requests.ReadTimeout: + retries += 1 + print(f"Request #{retries} timed-out, retrying in 1s") + time.sleep(1) + + if not downloaded: + print(f"Couldn't download transactions for {account}") + continue + + transactions.update(downloaded) + + return transactions + + def dump(self, bank, downloaded): + with open("json/" + bank.name + ".json", "w") as f: + json.dump(downloaded, f) + + def generate_token(self): + self.token = self._client.generate_token() + print(f"New access token: {self.token}") + return self.token def requisition(self, id: str, country: str = "PT"): requisition = self._client.initialize_session( @@ -97,18 +107,6 @@ class PSD2Client(Extract): def country_banks(self, country: str): return self._client.institution.get_institutions(country) - @property - def client(self): - return self._client - - @property - def banks(self): - return self._banks - - @banks.setter - def banks(self, value): - self._banks = value - @property def start(self): return self._start @@ -125,10 +123,20 @@ class PSD2Client(Extract): def end(self, value): self._end = value - def __token(self): - if token := os.environ.get("TOKEN"): - return token - else: - token = self._client.generate_token() - print(f"New access token: {token}") - return token["access"] + # def __token(self): + # if token := os.environ.get("TOKEN"): + # return token + # else: + # token = self._client.generate_token() + # print(f"New access token: {token}") + # return token["access"] + + @property + def token(self): + return self._token + + @token.setter + def token(self, value): + if self._token: + print("Replacing existing token with {value}") + self._token = value diff --git a/poetry.lock b/poetry.lock index 0623ac2..0ba6ee6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -264,6 +264,73 @@ mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"] test = ["Pillow", "matplotlib", "pytest"] test-no-images = ["pytest"] +[[package]] +name = "coverage" +version = "7.2.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-7.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5"}, + {file = "coverage-7.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535"}, + {file = "coverage-7.2.3-cp310-cp310-win32.whl", hash = "sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91"}, + {file = "coverage-7.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4"}, + {file = "coverage-7.2.3-cp311-cp311-win32.whl", hash = "sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925"}, + {file = "coverage-7.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910"}, + {file = "coverage-7.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48"}, + {file = "coverage-7.2.3-cp37-cp37m-win32.whl", hash = "sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1"}, + {file = "coverage-7.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152"}, + {file = "coverage-7.2.3-cp38-cp38-win32.whl", hash = "sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22"}, + {file = "coverage-7.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4"}, + {file = "coverage-7.2.3-cp39-cp39-win32.whl", hash = "sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30"}, + {file = "coverage-7.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a"}, + {file = "coverage-7.2.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0"}, + {file = "coverage-7.2.3.tar.gz", hash = "sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + [[package]] name = "cycler" version = "0.11.0" @@ -276,6 +343,21 @@ files = [ {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, ] +[[package]] +name = "exceptiongroup" +version = "1.1.1" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, +] + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "flake8" version = "6.0.0" @@ -405,6 +487,18 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "kiwisolver" version = "1.4.4" @@ -742,6 +836,22 @@ files = [ docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "psycopg2" version = "2.9.6" @@ -804,6 +914,66 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pytest" +version = "7.3.0" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.3.0-py3-none-any.whl", hash = "sha256:933051fa1bfbd38a21e73c3960cebdad4cf59483ddba7696c48509727e17f201"}, + {file = "pytest-7.3.0.tar.gz", hash = "sha256:58ecc27ebf0ea643ebfdf7fb1249335da761a00c9f955bcd922349bcb68ee57d"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-mock" +version = "3.10.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, + {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -1040,4 +1210,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c1993dc151cd67977bd1d330fcef95fa6ea8fc8aa20f241b804bfec61d7a3a9f" +content-hash = "9d3d37a64fddc6654db1f2606001b81a2432e8f4f667f7146921942f59472614" diff --git a/pyproject.toml b/pyproject.toml index 0814837..a40e1ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,13 @@ sqlalchemy = "^2.0.9" [tool.poetry.group.dev.dependencies] black = "^23.3.0" flake8 = "^6.0.0" +pytest = "^7.3.0" +pytest-cov = "^4.0.0" +pytest-mock = "^3.10.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[pytest] +mock_use_standalone_module = true diff --git a/tests/mocks/nordigen.py b/tests/mocks/nordigen.py new file mode 100644 index 0000000..d0a284e --- /dev/null +++ b/tests/mocks/nordigen.py @@ -0,0 +1,82 @@ +id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + +accounts_id = { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2023-04-13T21:45:59.957Z", + "last_accessed": "2023-04-13T21:45:59.957Z", + "iban": "string", + "institution_id": "string", + "status": "DISCOVERED", + "owner_name": "string", +} + +accounts_id_transactions = { + "transactions": { + "booked": [ + { + "transactionId": "string", + "debtorName": "string", + "debtorAccount": {"iban": "string"}, + "transactionAmount": {"currency": "string", "amount": "328.18"}, + "bankTransactionCode": "string", + "bookingDate": "2023-01-14", + "valueDate": "2023-01-15", + "remittanceInformationUnstructured": "string", + }, + { + "transactionId": "string", + "transactionAmount": {"currency": "string", "amount": "947.26"}, + "bankTransactionCode": "string", + "bookingDate": "2023-02-14", + "valueDate": "2023-02-15", + "remittanceInformationUnstructured": "string", + }, + ], + "pending": [ + { + "transactionAmount": {"currency": "string", "amount": "float"}, + "valueDate": "2023-04-14", + "remittanceInformationUnstructured": "string", + } + ], + } +} + +requisitions = { + "count": 123, + "next": "https://ob.nordigen.com/api/v2/requisitions/?limit=100&offset=0", + "previous": "https://ob.nordigen.com/api/v2/requisitions/?limit=100&offset=0", + "results": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2023-04-13T21:43:45.027Z", + "redirect": "string", + "status": "CR", + "institution_id": "string", + "agreement": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "reference": "string", + "accounts": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], + "user_language": "strin", + "link": "https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/{$INSTITUTION_ID}", + "ssn": "string", + "account_selection": False, + "redirect_immediate": False, + } + ], +} + +requisitions_id = { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2023-04-13T21:45:12.336Z", + "redirect": "string", + "status": "CR", + "institution_id": "string", + "agreement": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "reference": "string", + "accounts": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], + "user_language": "strin", + "link": "https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/{$INSTITUTION_ID}", + "ssn": "string", + "account_selection": False, + "redirect_immediate": False, +} diff --git a/tests/test_psd2.py b/tests/test_psd2.py new file mode 100644 index 0000000..4686109 --- /dev/null +++ b/tests/test_psd2.py @@ -0,0 +1,94 @@ +import datetime as dt +from decimal import Decimal +import pytest +import requests + +import mocks.nordigen as mock + +from pfbudget.db.model import Bank, BankTransaction, Nordigen +from pfbudget.extract.credentials import Credentials +from pfbudget.extract.exceptions import BankError, CredentialsError +from pfbudget.extract.psd2 import PSD2Client + + +class MockGet: + def __init__(self, status_code: int = 200, exception=None): + self._ok = True if status_code == 200 else False + self._status_code = status_code + self._exception = exception + + def __call__(self, *args, **kwargs): + if self._exception: + raise self._exception + + self.url = kwargs["url"] + return self + + @property + def ok(self): + return self._ok + + @property + def status_code(self): + return self._status_code + + def json(self): + if self.url.endswith("accounts/" + mock.id + "/"): + return mock.accounts_id + elif self.url.endswith("accounts/" + mock.id + "/transactions/"): + return mock.accounts_id_transactions + elif self.url.endswith("requisitions/"): + return mock.requisitions + elif self.url.endswith("requisitions/" + mock.id + "/"): + return mock.requisitions_id + + +@pytest.fixture(autouse=True) +def mock_requests(monkeypatch): + monkeypatch.setattr("requests.get", MockGet()) + monkeypatch.delattr("requests.post") + monkeypatch.delattr("requests.put") + monkeypatch.delattr("requests.delete") + + +@pytest.fixture +def client() -> PSD2Client: + credentials = Credentials("ID", "KEY", "TOKEN") + return PSD2Client(credentials) + + +@pytest.fixture +def banks() -> list[Bank]: + bank = Bank("Bank#1", "", "") + bank.nordigen = Nordigen("", "", mock.id, False) + return [bank] + + +class TestExtractPSD2: + def test_empty_credentials(self): + cred = Credentials("", "") + with pytest.raises(CredentialsError): + PSD2Client(cred) + + def test_empty_banks(self, client): + with pytest.raises(BankError): + client.extract([]) + + def test_no_psd2_bank(self, client): + with pytest.raises(BankError): + client.extract([Bank("", "", "")]) + + def test_timeout(self, monkeypatch, client, banks): + monkeypatch.setattr("requests.get", MockGet(exception=requests.ReadTimeout)) + with pytest.raises(requests.Timeout): + client.extract(banks) + + def test_extract(self, client, banks): + assert client.extract(banks) == [ + BankTransaction( + dt.date(2023, 1, 14), "string", Decimal("328.18"), "Bank#1" + ), + BankTransaction( + dt.date(2023, 2, 14), "string", Decimal("947.26"), "Bank#1" + ), + ]