conservancy_beancount/tests/test_meta_receipt.py
2020-05-01 16:18:46 -04:00

373 lines
14 KiB
Python

"""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 <https://www.gnu.org/licenses/>.
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,other_acct', testutil.combine_values(
ACCOUNTS,
NOT_REQUIRED_ACCOUNTS,
))
def test_not_required_on_chargeback_txns(hook, test_acct, other_acct):
if test_acct.required_types & PostType.DEBIT:
check(hook, test_acct, other_acct, None,
txn_meta={'tax-implication': 'Chargeback'},
check_type=PostType.DEBIT,
)
@pytest.mark.parametrize('test_acct,other_acct', testutil.combine_values(
ACCOUNTS,
NOT_REQUIRED_ACCOUNTS,
))
def test_not_required_on_chargeback_posts(hook, test_acct, other_acct):
if test_acct.required_types & PostType.DEBIT:
check(hook, test_acct, other_acct, None,
post_meta={'tax-implication': 'Chargeback'},
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)