From eb5c1781f0facdf0d897ea3e0bcd5bb31ff059e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Sat, 15 Apr 2023 13:53:03 +0100 Subject: [PATCH] Get the PSD2 client under testing Having a comprehension unit testing allows for peace of mind refactoring. Changed the PSD2Client constructor and extract method to accept credentials and banks, respectively. Create Exception tree for the Extract module. Adds mocks to the NordigenClient requests' methods. --- pfbudget/core/manager.py | 29 ++++-- pfbudget/extract/credentials.py | 11 ++ pfbudget/extract/exceptions.py | 10 ++ pfbudget/extract/extract.py | 2 +- pfbudget/extract/psd2.py | 142 +++++++++++++------------- poetry.lock | 172 +++++++++++++++++++++++++++++++- pyproject.toml | 6 ++ tests/mocks/nordigen.py | 82 +++++++++++++++ tests/test_psd2.py | 94 +++++++++++++++++ 9 files changed, 472 insertions(+), 76 deletions(-) create mode 100644 pfbudget/extract/credentials.py create mode 100644 pfbudget/extract/exceptions.py create mode 100644 tests/mocks/nordigen.py create mode 100644 tests/test_psd2.py 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" + ), + ]