conservancy_beancount/tests/test_meta_receipt.py

377 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.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': '!'})