"""Test validation of entity 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 pytest from . import testutil from conservancy_beancount.plugin import meta_entity VALID_VALUES = { # Classic entity: LastName-FirstName 'Smith-Alex', # Various people and companies have one-word names # Digits are allowed, as part of a name or standalone 'Company19', 'Company-19', # No case requirements 'boyd-danah', # No limit on the number of parts of the name 'B-van-der-A', # Names that have no ASCII are allowed, with or without dash separators '田中流星', '田中-流星', 'スミスダコタ', 'スミス-ダコタ', 'Яшин-Данила', # Governments, using : as a hierarchy separator 'BE', 'US:KY', 'CA:ON', # The PayPal importer allows ASCII punctuation in entity metadata 'Du-Bois-W.-E.-B.', "O'Malley-Thomas", 'O`Malley-Thomas', # import2ledger produces entities that end with - # That's probably a bug, but allow it for now. 'foo-', } INVALID_VALUES = { # Starting with a - is not allowed '-foo', # Names that can be reduced to ASCII should be # Producers should change this to Uberentity or Ueberentity # I am not wild about this rule and would like to relax it—it's mostly # based on an expectation that entities are typed in by an American. That's # true less and less and it seems like we should reduce the amount of # mangling producers are expected to do. But it's the rule for today. 'Überentity', # Whitespace is never allowed 'Alex Smith', '田中\u00A0流星', # Non-breaking space # Non-ASCII punctuation is not allowed 'Яшин—Данила', # em dash 'O’Malley-Thomas', # Right-angled apostrophe 'Du-Bois-W。-E。-B。', # Japanese period } ANONYMOUS_VALUES = { # Values produced by various importers that should be translated to # Anonymous. '', ' ', '-', '--', '-----', '_', ' _ ', '.', } TEST_KEY = 'entity' @pytest.fixture(scope='module') def hook(): config = testutil.TestConfig() return meta_entity.MetaEntity(config) @pytest.mark.parametrize('src_value', VALID_VALUES) def test_valid_values_on_postings(hook, src_value): txn = testutil.Transaction(postings=[ ('Assets:Cash', -25), ('Expenses:General', 25, {TEST_KEY: src_value}), ]) assert not any(hook.run(txn)) @pytest.mark.parametrize('src_value', ANONYMOUS_VALUES) def test_anonymous_values_on_postings(hook, src_value): txn = testutil.Transaction(postings=[ ('Assets:Cash', -25), ('Expenses:General', 25, {TEST_KEY: src_value}), ]) assert not any(hook.run(txn)) assert txn.postings[-1].meta[TEST_KEY] == 'Anonymous' @pytest.mark.parametrize('src_value', INVALID_VALUES) def test_invalid_values_on_postings(hook, src_value): txn = testutil.Transaction(postings=[ ('Assets:Cash', -25), ('Expenses:General', 25, {TEST_KEY: src_value}), ]) errors = list(hook.run(txn)) assert len(errors) == 1 assert errors[0].message == "Expenses:General has invalid entity: {}".format(src_value) @pytest.mark.parametrize('src_value', VALID_VALUES) def test_valid_values_on_transactions(hook, src_value): txn = testutil.Transaction(payee='Payee', **{TEST_KEY: src_value}, postings=[ ('Assets:Cash', -25), ('Expenses:General', 25), ]) assert not any(hook.run(txn)) # Make sure payee doesn't overwrite metadata. See payee test below. assert txn.meta[TEST_KEY] == src_value @pytest.mark.parametrize('src_value', ANONYMOUS_VALUES) def test_anonymous_values_on_transactions(hook, src_value): txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ ('Assets:Cash', -25), ('Expenses:General', 25), ]) assert not any(hook.run(txn)) assert txn.meta[TEST_KEY] == 'Anonymous' @pytest.mark.parametrize('src_value', INVALID_VALUES) def test_invalid_values_on_transactions(hook, src_value): txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[ ('Assets:Cash', -25), ('Expenses:General', 25), ]) errors = list(hook.run(txn)) assert 1 <= len(errors) <= 2 assert all(error.message == "transaction has invalid entity: {}".format(src_value) for error in hook.run(txn)) @pytest.mark.parametrize('src_value', VALID_VALUES) def test_valid_values_on_payee(hook, src_value): txn = testutil.Transaction(payee=src_value, postings=[ ('Assets:Cash', -25), ('Expenses:General', 25), ]) assert not any(hook.run(txn)) # In this case, we want the hook to set metadata to make it easier to # write bean-queries. assert txn.meta[TEST_KEY] == src_value @pytest.mark.parametrize('src_value', ANONYMOUS_VALUES) def test_anonymous_values_on_payee(hook, src_value): txn = testutil.Transaction(payee=src_value, postings=[ ('Assets:Cash', -25), ('Expenses:General', 25), ]) assert not any(hook.run(txn)) assert txn.meta[TEST_KEY] == 'Anonymous' @pytest.mark.parametrize('src_value', INVALID_VALUES) def test_invalid_values_on_payee(hook, src_value): txn = testutil.Transaction(payee=src_value, postings=[ ('Assets:Cash', -25), ('Expenses:General', 25), ]) errors = list(hook.run(txn)) assert 1 <= len(errors) <= 2 assert all(error.message == "transaction has invalid entity: {}".format(src_value) for error in hook.run(txn)) @pytest.mark.parametrize('payee,src_value', testutil.combine_values( INVALID_VALUES, VALID_VALUES, )) def test_invalid_payee_but_valid_metadata(hook, payee, src_value): txn = testutil.Transaction(**{'payee': payee, TEST_KEY: src_value}, postings=[ ('Assets:Cash', -25), ('Expenses:Other', 25), ]) assert not any(hook.run(txn)) def test_mixed_sources(hook): txn = testutil.Transaction(payee='Payee', postings=[ ('Income:Donations', -5), ('Equity:Funds:Restricted', 5, {TEST_KEY: 'Entity'}), ]) assert not any(hook.run(txn)) assert txn.postings[-1].meta[TEST_KEY] == 'Entity' assert txn.meta[TEST_KEY] == 'Payee' try: assert txn.postings[0].meta[TEST_KEY] == 'Payee' except (KeyError, TypeError): pass @pytest.mark.parametrize('account,required', [ ('Assets:Bank:Checking', False), ('Assets:Cash', False), ('Assets:Receivable:Accounts', True), ('Assets:Receivable:Loans', True), ('Equity:OpeningBalances', False), ('Expenses:General', True), ('Income:Donations', True), ('Liabilities:CreditCard', False), ('Liabilities:Payable:Accounts', True), ('Liabilities:Payable:Vacation', True), ('Liabilities:UnearnedIncome:Donations', False), ]) def test_which_accounts_required_on(hook, account, required): txn = testutil.Transaction(postings=[ ('Assets:Checking', -25), (account, 25), ]) errors = list(hook.run(txn)) if not required: assert not errors else: assert errors assert any(error.message == "{} missing entity".format(account) for error in errors) def test_dont_set_entity_none(hook): txn = testutil.Transaction(postings=[ ('Expenses:Other', 5), ('Assets:Cash', -5), ]) assert any(hook.run(txn)) assert 'entity' not in txn.meta for post in txn.postings: assert post.meta is None or 'entity' not in post.meta def test_not_required_on_opening(hook): txn = testutil.OpeningBalance() assert not list(hook.run(txn)) @pytest.mark.parametrize('date,need_value', [ (testutil.EXTREME_FUTURE_DATE, False), (testutil.FUTURE_DATE, True), (testutil.FY_START_DATE, True), (testutil.FY_MID_DATE, True), (testutil.PAST_DATE, False), ]) def test_required_by_date(hook, date, need_value): txn = testutil.Transaction(date=date, postings=[ ('Income:Donations', -10), ('Assets:Checking', 10), ]) assert any(hook.run(txn)) == need_value def test_still_required_on_flagged(hook): txn = testutil.Transaction(flag='!', postings=[ ('Income:Donations', -10), ('Assets:Checking', 10), ]) assert list(hook.run(txn))