"""Test validation of receipt metadata""" # Copyright © 2020 Brett Smith # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import decimal import enum import itertools import random import typing import pytest from . import testutil from conservancy_beancount.plugin import meta_receipt TEST_KEY = 'receipt' class PostType(enum.IntFlag): NONE = 0 CREDIT = 1 DEBIT = 2 BOTH = CREDIT | DEBIT class AccountForTesting(typing.NamedTuple): name: str required_types: PostType fallback_meta: typing.Optional[str] def missing_message(self, include_fallback=True): if self.fallback_meta is None or not include_fallback: rest = "" else: rest = f"/{self.fallback_meta}" return f"{self.name} missing {TEST_KEY}{rest}" def wrong_type_message(self, wrong_value, key=TEST_KEY): expect_type = 'Decimal' if key == 'check-id' else 'str' return "{} has wrong type of {}: expected {} but is a {}".format( self.name, key, expect_type, type(wrong_value).__name__, ) ACCOUNTS = [AccountForTesting._make(t) for t in [ ('Assets:Bank:CheckCard', PostType.CREDIT, 'check'), ('Assets:Bank:CheckCard', PostType.DEBIT, 'check-id'), ('Assets:Cash', PostType.BOTH, None), ('Assets:Checking', PostType.CREDIT, 'check'), ('Assets:Checking', PostType.DEBIT, 'check-id'), ('Assets:Savings', PostType.BOTH, None), ('Liabilities:CreditCard', PostType.CREDIT, None), ('Liabilities:CreditCard', PostType.DEBIT, 'invoice'), ]] ACCOUNTS_WITH_LINK_FALLBACK = [acct for acct in ACCOUNTS if acct.fallback_meta and acct.fallback_meta != 'check-id'] ACCOUNTS_WITH_CHECK_ID_FALLBACK = [acct for acct in ACCOUNTS if acct.fallback_meta == 'check-id'] ACCOUNTS_WITHOUT_FALLBACKS = [acct for acct in ACCOUNTS if not acct.fallback_meta] KNOWN_FALLBACKS = {acct.fallback_meta for acct in ACCOUNTS if acct.fallback_meta} # These are mostly fill-in values. # We don't need to run every test on every value for these, just enough to # convince ourselves the hook never reports errors against these accounts. # Making this a iterator rather than a sequence means testutil.combine_values # doesn't require the decorated test to go over every value, which in turn # trims unnecessary test time. NOT_REQUIRED_ACCOUNTS = itertools.cycle([ # Only paypal-id is required for PayPal transactions 'Assets:PayPal', 'Assets:Prepaid:Expenses', 'Assets:Receivable:Accounts', 'Equity:Retained', 'Expenses:Other', 'Income:Other', 'Liabilities:Payable:Accounts', 'Liabilities:UnearnedIncome:Donations', ]) CHECK_IDS = (decimal.Decimal(n) for n in itertools.count(1)) def BAD_CHECK_IDS(): # Valid check-id values are positive integers yield decimal.Decimal(0) yield -next(CHECK_IDS) yield next(CHECK_IDS) * decimal.Decimal('1.1') BAD_CHECK_IDS = BAD_CHECK_IDS() def check(hook, test_acct, other_acct, expected, *, txn_meta={}, post_meta={}, check_type=PostType.BOTH, min_amt=0): check_type &= test_acct.required_types assert check_type, "tried to test a non-applicable account" if check_type == PostType.BOTH: check(hook, test_acct, other_acct, expected, txn_meta=txn_meta, post_meta=post_meta, check_type=PostType.CREDIT) check_type = PostType.DEBIT amount = decimal.Decimal('{:.02f}'.format(min_amt + random.random() * 100)) if check_type == PostType.DEBIT: amount = -amount txn = testutil.Transaction(**txn_meta, postings=[ (test_acct.name, amount, post_meta), (other_acct, -amount), ]) actual = {error.message for error in hook.run(txn)} if expected is None: assert not actual elif isinstance(expected, str): assert expected in actual else: assert actual == expected @pytest.fixture(scope='module') def hook(): config = testutil.TestConfig() return meta_receipt.MetaReceipt(config) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, testutil.LINK_METADATA_STRINGS, )) def test_valid_receipt_on_post(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, None, post_meta={TEST_KEY: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, testutil.NON_LINK_METADATA_STRINGS, )) def test_invalid_receipt_on_post(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, {test_acct.missing_message()}, post_meta={TEST_KEY: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, testutil.NON_STRING_METADATA_VALUES, )) def test_bad_type_receipt_on_post(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, test_acct.wrong_type_message(value), post_meta={TEST_KEY: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, testutil.LINK_METADATA_STRINGS, )) def test_valid_receipt_on_txn(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, None, txn_meta={TEST_KEY: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, testutil.NON_LINK_METADATA_STRINGS, )) def test_invalid_receipt_on_txn(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, {test_acct.missing_message()}, txn_meta={TEST_KEY: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, testutil.NON_STRING_METADATA_VALUES, )) def test_bad_type_receipt_on_txn(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, test_acct.wrong_type_message(value), txn_meta={TEST_KEY: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.LINK_METADATA_STRINGS, )) def test_valid_fallback_on_post(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, None, post_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.NON_LINK_METADATA_STRINGS, )) def test_invalid_fallback_on_post(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, {test_acct.missing_message()}, post_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.NON_STRING_METADATA_VALUES, )) def test_bad_type_fallback_on_post(hook, test_acct, other_acct, value): expected = { test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, post_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.LINK_METADATA_STRINGS, )) def test_valid_fallback_on_txn(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, None, txn_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.NON_LINK_METADATA_STRINGS, )) def test_invalid_fallback_on_txn(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, {test_acct.missing_message()}, txn_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.NON_STRING_METADATA_VALUES, )) def test_bad_type_fallback_on_txn(hook, test_acct, other_acct, value): expected = { test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, txn_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_CHECK_ID_FALLBACK, NOT_REQUIRED_ACCOUNTS, CHECK_IDS, )) def test_valid_check_id_on_post(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, None, post_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_CHECK_ID_FALLBACK, NOT_REQUIRED_ACCOUNTS, BAD_CHECK_IDS, )) def test_invalid_check_id_on_post(hook, test_acct, other_acct, value): expected = { test_acct.missing_message(), f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}", } check(hook, test_acct, other_acct, expected, post_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_CHECK_ID_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.NON_STRING_METADATA_VALUES, )) def test_bad_type_check_id_on_post(hook, test_acct, other_acct, value): if isinstance(value, decimal.Decimal): value = '' expected = { test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, post_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_CHECK_ID_FALLBACK, NOT_REQUIRED_ACCOUNTS, CHECK_IDS, )) def test_valid_check_id_on_txn(hook, test_acct, other_acct, value): check(hook, test_acct, other_acct, None, txn_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_CHECK_ID_FALLBACK, NOT_REQUIRED_ACCOUNTS, BAD_CHECK_IDS, )) def test_invalid_check_id_on_txn(hook, test_acct, other_acct, value): expected = { test_acct.missing_message(), f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}", } check(hook, test_acct, other_acct, expected, txn_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_CHECK_ID_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.NON_STRING_METADATA_VALUES, )) def test_bad_type_check_id_on_txn(hook, test_acct, other_acct, value): if isinstance(value, decimal.Decimal): value = '' expected = { test_acct.missing_message(), test_acct.wrong_type_message(value, test_acct.fallback_meta), } check(hook, test_acct, other_acct, expected, txn_meta={test_acct.fallback_meta: value}) @pytest.mark.parametrize('test_acct,other_acct,key,value', testutil.combine_values( ACCOUNTS_WITHOUT_FALLBACKS, NOT_REQUIRED_ACCOUNTS, KNOWN_FALLBACKS, testutil.LINK_METADATA_STRINGS, )) def test_fallback_not_accepted_on_other_accounts(hook, test_acct, other_acct, key, value): check(hook, test_acct, other_acct, {test_acct.missing_message()}, post_meta={key: value}) @pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values( ACCOUNTS_WITH_LINK_FALLBACK, NOT_REQUIRED_ACCOUNTS, testutil.LINK_METADATA_STRINGS, )) def test_fallback_on_zero_amount_postings(hook, test_acct, other_acct, value): # Unfortunately it does happen that we get donations that go 100% to # banking fees, and our importer writes a zero-amount posting to the # Assets account. txn = testutil.Transaction(postings=[ ('Income:Donations', '-.1'), ('Expenses:BankingFees', '.1'), (test_acct.name, 0, {test_acct.fallback_meta: value}), ]) assert not list(hook.run(txn)) @pytest.mark.parametrize('test_acct,equity_acct', testutil.combine_values( ACCOUNTS, testutil.OPENING_EQUITY_ACCOUNTS, )) def test_not_required_on_opening(hook, test_acct, equity_acct): check(hook, test_acct, equity_acct, None) @pytest.mark.parametrize('test_acct,other_acct', testutil.combine_values( ACCOUNTS, NOT_REQUIRED_ACCOUNTS, )) def test_not_required_on_flagged(hook, test_acct, other_acct): check(hook, test_acct, other_acct, None, txn_meta={'flag': '!'})