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.
This commit is contained in:
Luís Murta 2023-04-15 13:53:03 +01:00
parent 184628dcfc
commit eb5c1781f0
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
9 changed files with 472 additions and 76 deletions

View File

@ -1,6 +1,8 @@
import csv import csv
import dotenv
from pathlib import Path from pathlib import Path
import pickle import pickle
import os
import webbrowser import webbrowser
from pfbudget.common.types import Operation from pfbudget.common.types import Operation
@ -25,9 +27,13 @@ from pfbudget.db.model import (
Transaction, Transaction,
TransactionCategory, TransactionCategory,
) )
from pfbudget.extract.credentials import Credentials
from pfbudget.extract.extract import Extract
from pfbudget.extract.psd2 import PSD2Client from pfbudget.extract.psd2 import PSD2Client
from pfbudget.extract.parsers import parse_data from pfbudget.extract.parsers import parse_data
dotenv.load_dotenv()
class Manager: class Manager:
def __init__(self, db: str, verbosity: int = 0): def __init__(self, db: str, verbosity: int = 0):
@ -72,16 +78,16 @@ class Manager:
session.add(sorted(transactions)) session.add(sorted(transactions))
case Operation.Download: case Operation.Download:
client = PSD2Client() client = Manager.nordigen_client()
with self.db.session() as session: with self.db.session() as session:
if len(params[3]) == 0: if len(params[3]) == 0:
client.banks = session.get(Bank, Bank.nordigen) banks = session.get(Bank, Bank.nordigen)
else: else:
client.banks = session.get(Bank, Bank.name, params[3]) banks = session.get(Bank, Bank.name, params[3])
session.expunge_all() session.expunge_all()
client.start = params[0] client.start = params[0]
client.end = params[1] client.end = params[1]
transactions = client.parse() transactions = client.extract(banks)
# dry-run # dry-run
if not params[2]: if not params[2]:
@ -116,15 +122,15 @@ class Manager:
session.remove_by_name(Nordigen, params) session.remove_by_name(Nordigen, params)
case Operation.Token: case Operation.Token:
PSD2Client().token() Manager.nordigen_client().generate_token()
case Operation.RequisitionId: 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]}") print(f"Opening {link} to request access to {params[0]}")
webbrowser.open(link) webbrowser.open(link)
case Operation.PSD2CountryBanks: case Operation.PSD2CountryBanks:
banks = PSD2Client().country_banks(params[0]) banks = Manager.nordigen_client().country_banks(params[0])
print(banks) print(banks)
case ( case (
@ -411,3 +417,12 @@ class Manager:
@db.setter @db.setter
def db(self, url: str): def db(self, url: str):
self._db = url 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)

View File

@ -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

View File

@ -0,0 +1,10 @@
class ExtractError(Exception):
pass
class BankError(ExtractError):
pass
class CredentialsError(ExtractError):
pass

View File

@ -5,5 +5,5 @@ from pfbudget.db.model import Transaction
class Extract(ABC): class Extract(ABC):
@abstractmethod @abstractmethod
def parse(self) -> list[Transaction]: def extract(self) -> list[Transaction]:
return NotImplementedError return NotImplementedError

View File

@ -1,75 +1,55 @@
import datetime as dt import datetime as dt
import dotenv
import json import json
import nordigen import nordigen
import os
import requests import requests
import time import time
import uuid import uuid
from typing import Sequence
import pfbudget.db.model as t import pfbudget.db.model as t
from pfbudget.utils.converters import convert from pfbudget.utils.converters import convert
from .credentials import Credentials
from .exceptions import BankError, CredentialsError
from .extract import Extract from .extract import Extract
dotenv.load_dotenv()
class PSD2Client(Extract): class PSD2Client(Extract):
redirect_url = "https://murta.dev" redirect_url = "https://murta.dev"
def __init__(self): def __init__(self, credentials: Credentials):
super().__init__() super().__init__()
if not (key := os.environ.get("SECRET_KEY")) or not ( if not credentials.valid():
id := os.environ.get("SECRET_ID") raise CredentialsError
):
raise
self._client = nordigen.NordigenClient( self._client = nordigen.NordigenClient(
secret_key=key, secret_key=credentials.key, secret_id=credentials.id, timeout=5
secret_id=id,
) )
self._client.token = self.__token() if credentials.token:
self._token = credentials.token
self._start = dt.date.min self._start = dt.date.min
self._end = dt.date.max self._end = dt.date.max
def parse(self) -> list[t.BankTransaction]: def extract(self, banks: Sequence[t.Bank]) -> list[t.BankTransaction]:
transactions = [] 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: for bank in banks:
print(f"Downloading from {bank}...") downloaded = None
requisition = self.client.requisition.get_requisition_by_id(
bank.nordigen.requisition_id
)
print(requisition)
for acc in requisition["accounts"]:
account = self._client.account_api(acc)
retries = 0
downloaded = {}
while retries < 3:
try: try:
downloaded = account.get_transactions() print(f"Downloading from {bank}...")
break downloaded = self.download(bank.nordigen.requisition_id)
except requests.ReadTimeout:
retries += 1
print(f"Request #{retries} timed-out, retrying in 1s")
time.sleep(1)
except requests.HTTPError as e: except requests.HTTPError as e:
retries += 1 print(f"There was an issue downloading from {bank.name} -> {e}")
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 continue
with open("json/" + bank.name + ".json", "w") as f: if downloaded:
json.dump(downloaded, f) self.dump(bank, downloaded)
converted = [ converted = [
convert(t, bank) for t in downloaded["transactions"]["booked"] convert(t, bank) for t in downloaded["transactions"]["booked"]
@ -81,10 +61,40 @@ class PSD2Client(Extract):
return sorted(transactions) return sorted(transactions)
def token(self): def download(self, requisition_id):
token = self._client.generate_token() requisition = self._client.requisition.get_requisition_by_id(requisition_id)
print(f"New access token: {token}") print(requisition)
return token
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"): def requisition(self, id: str, country: str = "PT"):
requisition = self._client.initialize_session( requisition = self._client.initialize_session(
@ -97,18 +107,6 @@ class PSD2Client(Extract):
def country_banks(self, country: str): def country_banks(self, country: str):
return self._client.institution.get_institutions(country) 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 @property
def start(self): def start(self):
return self._start return self._start
@ -125,10 +123,20 @@ class PSD2Client(Extract):
def end(self, value): def end(self, value):
self._end = value self._end = value
def __token(self): # def __token(self):
if token := os.environ.get("TOKEN"): # if token := os.environ.get("TOKEN"):
return token # return token
else: # else:
token = self._client.generate_token() # token = self._client.generate_token()
print(f"New access token: {token}") # print(f"New access token: {token}")
return token["access"] # 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

172
poetry.lock generated
View File

@ -264,6 +264,73 @@ mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"]
test = ["Pillow", "matplotlib", "pytest"] test = ["Pillow", "matplotlib", "pytest"]
test-no-images = ["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]] [[package]]
name = "cycler" name = "cycler"
version = "0.11.0" version = "0.11.0"
@ -276,6 +343,21 @@ files = [
{file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, {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]] [[package]]
name = "flake8" name = "flake8"
version = "6.0.0" version = "6.0.0"
@ -405,6 +487,18 @@ files = [
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, {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]] [[package]]
name = "kiwisolver" name = "kiwisolver"
version = "1.4.4" 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)"] 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)"] 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]] [[package]]
name = "psycopg2" name = "psycopg2"
version = "2.9.6" version = "2.9.6"
@ -804,6 +914,66 @@ files = [
[package.extras] [package.extras]
diagrams = ["jinja2", "railroad-diagrams"] 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.8.2" version = "2.8.2"
@ -1040,4 +1210,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "c1993dc151cd67977bd1d330fcef95fa6ea8fc8aa20f241b804bfec61d7a3a9f" content-hash = "9d3d37a64fddc6654db1f2606001b81a2432e8f4f667f7146921942f59472614"

View File

@ -22,7 +22,13 @@ sqlalchemy = "^2.0.9"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^23.3.0" black = "^23.3.0"
flake8 = "^6.0.0" flake8 = "^6.0.0"
pytest = "^7.3.0"
pytest-cov = "^4.0.0"
pytest-mock = "^3.10.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[pytest]
mock_use_standalone_module = true

82
tests/mocks/nordigen.py Normal file
View File

@ -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,
}

94
tests/test_psd2.py Normal file
View File

@ -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"
),
]