diff --git a/ofx_processor/main.py b/ofx_processor/main.py index 99630db..7f3075a 100644 --- a/ofx_processor/main.py +++ b/ofx_processor/main.py @@ -19,4 +19,4 @@ cli.add_command(ynab.config, name="config") discover_processors(cli) if __name__ == "__main__": - cli() + cli() # pragma: nocover diff --git a/ofx_processor/processors/bpvf.py b/ofx_processor/processors/bpvf.py index 48867b9..271b3b4 100644 --- a/ofx_processor/processors/bpvf.py +++ b/ofx_processor/processors/bpvf.py @@ -54,11 +54,6 @@ class BpvfProcessor(Processor): sys.exit(1) ofx = parser.convert() - - if ofx is None: - click.secho("Couldn't convert ofx file", fg="red") - sys.exit(1) - return ofx.statements[0].transactions @staticmethod diff --git a/ofx_processor/utils/ynab.py b/ofx_processor/utils/ynab.py index 641595a..2b313c7 100644 --- a/ofx_processor/utils/ynab.py +++ b/ofx_processor/utils/ynab.py @@ -1,5 +1,6 @@ import configparser import os +import sys import click import requests @@ -55,7 +56,15 @@ def push_transactions(transactions, account): click.pause() click.edit(filename=config_file) - config.read(config_file) + try: + config.read(config_file) + except configparser.Error as e: + click.secho(f"Error while parsing config file: {str(e)}", fg="red", bold=True) + click.secho("Opening the file...") + click.pause() + click.edit(filename=config_file) + click.secho("Exiting...", fg="red", bold=True) + sys.exit(1) section = config[account] budget_id = section["budget"] url = f"{BASE_URL}/budgets/{budget_id}/transactions" diff --git a/tests/samples/bpvf_transactions.json b/tests/samples/bpvf_transactions.json new file mode 100644 index 0000000..7f98e5b --- /dev/null +++ b/tests/samples/bpvf_transactions.json @@ -0,0 +1,140 @@ +{ + "transactions": [ + { + "date": "2020-02-26", + "amount": -9660, + "payee_name": "PRLV SEPA Company 3", + "memo": "123456789 PAYPAL 542UHBON", + "import_id": "YNAB:-9660:2020-02-26:1", + "account_id": "" + }, + { + "date": "2020-02-25", + "amount": -2400, + "payee_name": "H.I.K 69VILLEURBANNE", + "memo": "240220 CB****5555", + "import_id": "YNAB:-2400:2020-02-25:1", + "account_id": "" + }, + { + "date": "2020-02-25", + "amount": -39200, + "payee_name": "DELIVEROO FR WWW", + "memo": "230220 CB****5555 39,20EUR 1 EURO = 1,000000", + "import_id": "YNAB:-39200:2020-02-25:1", + "account_id": "" + }, + { + "date": "2020-02-25", + "amount": -9990, + "payee_name": "PRLV SEPA Company 1", + "memo": "Votre abonnement mobile: 06XXXXX 6498165189060897", + "import_id": "YNAB:-9990:2020-02-25:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": -7500, + "payee_name": "COMPANY FR LYON 6EME", + "memo": "210220 CB****5555 7,50EUR 1 EURO = 1,000000", + "import_id": "YNAB:-7500:2020-02-24:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": -34990, + "payee_name": "PRLV SEPA Company 2", + "memo": "24-02-2020 / 22-03-2020 56418710", + "import_id": "YNAB:-34990:2020-02-24:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": -2390, + "payee_name": "VIR Person 1", + "memo": "481840871 Splitwise", + "import_id": "YNAB:-2390:2020-02-24:1", + "account_id": "" + }, + { + "date": "2020-02-20", + "amount": 235000, + "payee_name": "VIREMENT Person 2", + "memo": "Cadeau", + "import_id": "YNAB:235000:2020-02-20:1", + "account_id": "" + }, + { + "date": "2020-02-20", + "amount": 55000, + "payee_name": "VIREMENT Company 3", + "memo": "48716508719", + "import_id": "YNAB:55000:2020-02-20:1", + "account_id": "" + }, + { + "date": "2020-02-19", + "amount": -55000, + "payee_name": "BDE INSA LYON 69VILLEURBANNE", + "memo": "170220 CB****5555", + "import_id": "YNAB:-55000:2020-02-19:1", + "account_id": "" + }, + { + "date": "2020-02-19", + "amount": -900, + "payee_name": "GUY AND SONS FR LYON", + "memo": "180220 CB****5555 0,90EUR 1 EURO = 1,000000", + "import_id": "YNAB:-900:2020-02-19:1", + "account_id": "" + }, + { + "date": "2020-02-19", + "amount": -1400, + "payee_name": "GUY AND SONS FR LYON", + "memo": "170220 CB****5555 1,40EUR 1 EURO = 1,000000", + "import_id": "YNAB:-1400:2020-02-19:1", + "account_id": "" + }, + { + "date": "2020-02-19", + "amount": -473500, + "payee_name": "VIR Person 1", + "memo": "65187460 Acompte cuisine 2", + "import_id": "YNAB:-473500:2020-02-19:1", + "account_id": "" + }, + { + "date": "2020-02-18", + "amount": -96960, + "payee_name": "PRLV SEPA Company 4", + "memo": "487105874 Amazon.fr 3X QC.(OJBIYN:ZOFEUBZF51871", + "import_id": "YNAB:-96960:2020-02-18:1", + "account_id": "" + }, + { + "date": "2020-02-17", + "amount": -232000, + "payee_name": "GRAND PARC PUY 85LES EPESSES", + "memo": "150220 CB****5555", + "import_id": "YNAB:-232000:2020-02-17:1", + "account_id": "" + }, + { + "date": "2020-02-17", + "amount": -1000, + "payee_name": "UBER BV NL HELP.UBER.CO", + "memo": "140220 CB****5555 1,00EUR 1 EURO = 1,000000", + "import_id": "YNAB:-1000:2020-02-17:1", + "account_id": "" + }, + { + "date": "2020-02-17", + "amount": 8600, + "payee_name": "VIREMENT Person 5", + "memo": "VIREMENT DE PERSON 6", + "import_id": "YNAB:8600:2020-02-17:1", + "account_id": "" + } + ] +} \ No newline at end of file diff --git a/tests/samples/ce_transactions.json b/tests/samples/ce_transactions.json new file mode 100644 index 0000000..dccf86b --- /dev/null +++ b/tests/samples/ce_transactions.json @@ -0,0 +1,52 @@ +{ + "transactions": [ + { + "date": "2020-02-25", + "amount": -21000, + "payee_name": "CB DECATHLON", + "memo": "FACT 240220", + "import_id": "YNAB:-21000:2020-02-25:1", + "account_id": "" + }, + { + "date": "2020-02-25", + "amount": -7000, + "payee_name": "PRLV COMPANY", + "memo": "Company Ref Prlvt SEPA 99-1KIBHEF-01 45871984", + "import_id": "YNAB:-7000:2020-02-25:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": -48130, + "payee_name": "CB 3403 MONOP", + "memo": "FACT 210220", + "import_id": "YNAB:-48130:2020-02-24:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": -1200, + "payee_name": "CB MALATIER", + "memo": "FACT 210220", + "import_id": "YNAB:-1200:2020-02-24:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": 2390, + "payee_name": "VIR SEPA PERSON 1", + "memo": "_", + "import_id": "YNAB:2390:2020-02-24:1", + "account_id": "" + }, + { + "date": "2020-02-24", + "amount": 14490, + "payee_name": "VIR SEPA PERSON 2", + "memo": "_", + "import_id": "YNAB:14490:2020-02-24:1", + "account_id": "" + } + ] +} \ No newline at end of file diff --git a/tests/samples/config_broken_duplicate_key.ini b/tests/samples/config_broken_duplicate_key.ini new file mode 100644 index 0000000..9553f31 --- /dev/null +++ b/tests/samples/config_broken_duplicate_key.ini @@ -0,0 +1,13 @@ +[DEFAULT] +token = +budget = + +[bpvf] +account = + +[revolut] +account = +account = + +[ce] +account = diff --git a/tests/samples/transactions.json b/tests/samples/revolut_transactions.json similarity index 100% rename from tests/samples/transactions.json rename to tests/samples/revolut_transactions.json diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index eb27189..a22cad0 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -1,3 +1,4 @@ +import json import unittest from unittest import mock from unittest.mock import call @@ -37,27 +38,72 @@ class UtilsTestCase(unittest.TestCase): add_command.assert_has_calls(calls, any_order=True) -class ConfigEditTestCase(unittest.TestCase): +class ConfigTestCase(unittest.TestCase): @mock.patch("click.edit") + @mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", "tests/samples") def test_config_edit(self, edit): - config_dir = "tests/samples" - ynab.DEFAULT_CONFIG_DIR = config_dir - expected_filename = f"{config_dir}/config.ini" - runner = CliRunner() from ofx_processor.main import cli # This is run at import time and the cli module is already imported before this test # so we need to re-run the add_command to make it available. cli.add_command(ynab.config, name="config") + runner = CliRunner() runner.invoke(cli, ["config", "edit"]) + + expected_filename = f"tests/samples/config.ini" + edit.assert_called_once_with(filename=expected_filename) + + @mock.patch("click.edit") + @mock.patch( + "ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", + "config_broken_duplicate_key.ini", + ) + @mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", "tests/samples") + def test_broken_config_file(self, edit): + from ofx_processor.main import cli + + # This is run at import time and the cli module is already imported before this test + # so we need to re-run the add_command to make it available. + utils.discover_processors(cli) + + runner = CliRunner() + result = runner.invoke(cli, ["revolut", "tests/samples/revolut.csv"]) + + expected_filename = "tests/samples/config_broken_duplicate_key.ini" + self.assertIn("Error while parsing config file", result.output) + edit.assert_called_once_with(filename=expected_filename) + + @mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", "file.ini") + @mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", "some/config/folder") + def test_get_config_file_name(self): + expected = "some/config/folder/file.ini" + self.assertEqual(ynab.get_config_file_name(), expected) + + @mock.patch("click.edit") + @mock.patch("os.makedirs") + @mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_FILENAME", "notfound.ini") + @mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", "tests/samples") + def test_missing_config_file(self, makedirs, edit): + from ofx_processor.main import cli + + # This is run at import time and the cli module is already imported before this test + # so we need to re-run the add_command to make it available. + utils.discover_processors(cli) + runner = CliRunner() + with mock.patch("ofx_processor.utils.ynab.open", mock.mock_open()) as mock_file: + result = runner.invoke(cli, ["revolut", "tests/samples/revolut.csv"]) + mock_file.assert_called_once() + makedirs.assert_called_once_with(ynab.DEFAULT_CONFIG_DIR, exist_ok=True) + self.assertIn("Editing config file", result.output) + expected_filename = ynab.get_config_file_name() edit.assert_called_once_with(filename=expected_filename) +@mock.patch("ofx_processor.utils.ynab.DEFAULT_CONFIG_DIR", "tests/samples") class DataTestCase(unittest.TestCase): @mock.patch("requests.post") def test_revolut_sends_data_only_created(self, post): - ynab.DEFAULT_CONFIG_DIR = "tests/samples" post.return_value.json.return_value = { "data": { "transactions": [ @@ -104,7 +150,6 @@ class DataTestCase(unittest.TestCase): @mock.patch("requests.post") def test_revolut_sends_data_some_created_some_duplicates(self, post): - ynab.DEFAULT_CONFIG_DIR = "tests/samples" post.return_value.json.return_value = { "data": { "transactions": [ @@ -152,7 +197,6 @@ class DataTestCase(unittest.TestCase): @mock.patch("requests.post") def test_revolut_sends_data_only_duplicates(self, post): - ynab.DEFAULT_CONFIG_DIR = "tests/samples" post.return_value.json.return_value = { "data": { "transactions": [], @@ -182,3 +226,45 @@ class DataTestCase(unittest.TestCase): self.assertIn("Processed 9 transactions total.", result.output) self.assertNotIn("transactions created in YNAB.", result.output) self.assertIn("9 transactions ignored (duplicates).", result.output) + + @mock.patch("requests.post") + def test_bpvf_sends_to_ynab(self, post): + from ofx_processor.main import cli + + # This is run at import time and the cli module is already imported before this test + # so we need to re-run the add_command to make it available. + utils.discover_processors(cli) + + with open("tests/samples/bpvf_transactions.json", encoding="utf-8") as f: + expected_data = json.load(f) + + expected_headers = {"Authorization": f"Bearer "} + expected_url = f"{ynab.BASE_URL}/budgets//transactions" + + runner = CliRunner() + runner.invoke(cli, ["bpvf", "tests/samples/bpvf.ofx"]) + + post.assert_called_once_with( + expected_url, json=expected_data, headers=expected_headers + ) + + @mock.patch("requests.post") + def test_ce_sends_to_ynab(self, post): + from ofx_processor.main import cli + + # This is run at import time and the cli module is already imported before this test + # so we need to re-run the add_command to make it available. + utils.discover_processors(cli) + + with open("tests/samples/ce_transactions.json", encoding="utf-8") as f: + expected_data = json.load(f) + + expected_headers = {"Authorization": f"Bearer "} + expected_url = f"{ynab.BASE_URL}/budgets//transactions" + + runner = CliRunner() + runner.invoke(cli, ["ce", "tests/samples/ce.ofx"]) + + post.assert_called_once_with( + expected_url, json=expected_data, headers=expected_headers + ) diff --git a/tests/test_ynab_integration.py b/tests/test_ynab_integration.py index f2b69d1..594a814 100644 --- a/tests/test_ynab_integration.py +++ b/tests/test_ynab_integration.py @@ -12,7 +12,7 @@ class YNABIntegrationTestCase(unittest.TestCase): with open("tests/samples/revolut_expected.json", encoding="utf-8") as f: transactions = json.load(f) - with open("tests/samples/transactions.json", encoding="utf-8") as f: + with open("tests/samples/revolut_transactions.json", encoding="utf-8") as f: expected_data = json.load(f) expected_headers = {"Authorization": f"Bearer "}