"""Test validation of receipt metadata""" # Copyright © 2020 Brett Smith # License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0 # # Full copyright and licensing details can be found at toplevel file # LICENSE.txt in the repository. 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.Sequence[str] = () def missing_message(self, include_fallback=True): return "{} missing {}{}{}".format( self.name, TEST_KEY, '/' if self.fallback_meta else '', '/'.join(self.fallback_meta), ) 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', 'invoice')), ('Assets:Bank:CheckCard', PostType.DEBIT, ('check-id',)), ('Assets:Cash', PostType.BOTH, ()), ('Assets:Checking', PostType.CREDIT, ('check', 'invoice')), ('Assets:Checking', PostType.DEBIT, ('check-id',)), ('Assets:Savings', PostType.BOTH, ()), ('Liabilities:CreditCard', PostType.CREDIT, ()), ('Liabilities:CreditCard', PostType.DEBIT, ('invoice',)), ]] ACCOUNTS_WITH_LINK_FALLBACK = [ (acct, fallback_key) for acct in ACCOUNTS for fallback_key in acct.fallback_meta if fallback_key != 'check-id' ] ACCOUNTS_WITH_CHECK_ID_FALLBACK = [ acct for acct in ACCOUNTS if 'check-id' in 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_pair,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_pair, other_acct, value): test_acct, meta_key = test_pair check(hook, test_acct, other_acct, None, post_meta={meta_key: value}) @pytest.mark.parametrize('test_pair,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_pair, other_acct, value): test_acct, meta_key = test_pair check(hook, test_acct, other_acct, {test_acct.missing_message()}, post_meta={meta_key: value}) @pytest.mark.parametrize('test_pair,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_pair, other_acct, value): test_acct, meta_key = test_pair expected = { test_acct.missing_message(), test_acct.wrong_type_message(value, meta_key), } check(hook, test_acct, other_acct, expected, post_meta={meta_key: value}) @pytest.mark.parametrize('test_pair,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_pair, other_acct, value): test_acct, meta_key = test_pair check(hook, test_acct, other_acct, None, txn_meta={meta_key: value}) @pytest.mark.parametrize('test_pair,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_pair, other_acct, value): test_acct, meta_key = test_pair check(hook, test_acct, other_acct, {test_acct.missing_message()}, txn_meta={meta_key: value}) @pytest.mark.parametrize('test_pair,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_pair, other_acct, value): test_acct, meta_key = test_pair expected = { test_acct.missing_message(), test_acct.wrong_type_message(value, meta_key), } check(hook, test_acct, other_acct, expected, txn_meta={meta_key: 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={'check-id': 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 check-id: {value}", } check(hook, test_acct, other_acct, expected, post_meta={'check-id': 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, 'check-id'), } check(hook, test_acct, other_acct, expected, post_meta={'check-id': 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={'check-id': 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 check-id: {value}", } check(hook, test_acct, other_acct, expected, txn_meta={'check-id': 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, 'check-id'), } check(hook, test_acct, other_acct, expected, txn_meta={'check-id': value}) @pytest.mark.parametrize('test_acct,other_acct,key,value', testutil.combine_values( [acct for acct in ACCOUNTS if not acct.fallback_meta], NOT_REQUIRED_ACCOUNTS, {key for acct in ACCOUNTS for key in acct.fallback_meta}, 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_pair,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_pair, 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. test_acct, meta_key = test_pair txn = testutil.Transaction(postings=[ ('Income:Donations', '-.1'), ('Expenses:BankingFees', '.1'), (test_acct.name, 0, {meta_key: value}), ]) assert not list(hook.run(txn)) @pytest.mark.parametrize('test_acct', ( acct for acct in ACCOUNTS if acct.name.startswith('Assets:') and acct.required_types & PostType.CREDIT )) def test_not_required_on_interest(hook, test_acct): check(hook, test_acct, 'Income:Interest', None, check_type=PostType.CREDIT) @pytest.mark.parametrize('test_acct', ( acct for acct in ACCOUNTS if acct.name.startswith('Assets:') and acct.required_types & PostType.DEBIT )) def test_required_on_reverse_interest(hook, test_acct): check(hook, test_acct, 'Income:Interest', {test_acct.missing_message()}, check_type=PostType.DEBIT) @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': '!'})