conservancy_beancount/tests/test_meta_receipt.py

237 lines
8.5 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" or {self.fallback_meta}"
return f"{self.name} missing {TEST_KEY}{rest}"
def wrong_type_message(self, wrong_value, key=TEST_KEY):
return "{} has wrong type of {}: expected str but is a {}".format(
self.name,
key,
type(wrong_value).__name__,
)
ACCOUNTS = [AccountForTesting._make(t) for t in [
('Assets:Bank:CheckCard', PostType.BOTH, 'check'),
('Assets:Cash', PostType.BOTH, None),
('Assets:Checking', PostType.BOTH, 'check'),
('Assets:Savings', PostType.BOTH, None),
('Liabilities:CreditCard', PostType.CREDIT, None),
('Liabilities:CreditCard', PostType.DEBIT, 'invoice'),
]]
ACCOUNTS_WITH_FALLBACKS = [acct for acct in ACCOUNTS if acct.fallback_meta]
ACCOUNTS_WITHOUT_FALLBACKS = [acct for acct in ACCOUNTS if not acct.fallback_meta]
KNOWN_FALLBACKS = {acct.fallback_meta for acct in ACCOUNTS_WITH_FALLBACKS}
# 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([
'Accrued:AccountsPayable',
'Accrued:AccountsReceivable',
'Assets:PrepaidExpenses',
'Assets:PrepaidVacation',
'Expenses:Other',
'Income:Other',
'UnearnedIncome:Donations',
])
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_FALLBACKS,
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_FALLBACKS,
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_FALLBACKS,
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(False),
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_FALLBACKS,
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_FALLBACKS,
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_FALLBACKS,
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(False),
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})