Changeset - 536b50b478d8
[Not reviewed]
0 10 0
Brett Smith - 4 years ago 2020-05-11 13:52:05
brettcsmith@brettcsmith.org
plugin: Don't validate transactions flagged with !. RT#10591.
10 files changed with 77 insertions and 17 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/plugin/core.py
Show inline comments
...
 
@@ -42,68 +42,64 @@ from ..beancount_types import (
 
    MetaValue,
 
    MetaValueEnum,
 
    Transaction,
 
)
 

	
 
### CONSTANTS
 

	
 
# I expect these will become configurable in the future, which is why I'm
 
# keeping them outside of a class, but for now constants will do.
 
DEFAULT_START_DATE: datetime.date = datetime.date(2020, 3, 1)
 
# The default stop date leaves a little room after so it's easy to test
 
# dates past the far end of the range.
 
DEFAULT_STOP_DATE: datetime.date = datetime.date(datetime.MAXYEAR, 1, 1)
 

	
 
### TYPE DEFINITIONS
 

	
 
HookName = str
 

	
 
Entry = TypeVar('Entry', bound=Directive)
 
class Hook(Generic[Entry], metaclass=abc.ABCMeta):
 
    DIRECTIVE: Type[Directive]
 
    HOOK_GROUPS: FrozenSet[HookName] = frozenset()
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        pass
 
        # Subclasses that need configuration should override __init__ to check
 
        # and store it.
 

	
 
    @abc.abstractmethod
 
    def run(self, entry: Entry) -> errormod.Iter: ...
 

	
 

	
 
class TransactionHook(Hook[Transaction]):
 
    DIRECTIVE = Transaction
 

	
 

	
 
### HELPER CLASSES
 

	
 
class LessComparable(metaclass=abc.ABCMeta):
 
    @abc.abstractmethod
 
    def __le__(self, other: Any) -> bool: ...
 

	
 
    @abc.abstractmethod
 
    def __lt__(self, other: Any) -> bool: ...
 

	
 

	
 
CT = TypeVar('CT', bound=LessComparable)
 
class _GenericRange(Generic[CT]):
 
    """Convenience class to check whether a value is within a range.
 

	
 
    `foo in generic_range` is equivalent to `start <= foo < stop`.
 
    Since we have multiple user-configurable ranges, having the check
 
    encapsulated in an object helps implement the check consistently, and
 
    makes it easier for subclasses to override.
 
    """
 

	
 
    def __init__(self, start: CT, stop: CT) -> None:
 
        self.start = start
 
        self.stop = stop
 

	
 
    def __repr__(self) -> str:
 
        return "{clsname}({self.start!r}, {self.stop!r})".format(
 
            clsname=type(self).__name__,
 
            self=self,
 
        )
 

	
 
    def __contains__(self, item: CT) -> bool:
 
        return self.start <= item < self.stop
...
 
@@ -149,76 +145,86 @@ class MetadataEnum:
 

	
 
    def __getitem__(self, key: MetaValueEnum) -> MetaValueEnum:
 
        """Return the standard value for `key`.
 

	
 
        Raises KeyError if `key` is not a known value or alias.
 
        """
 
        return self._aliases[key]
 

	
 
    def __iter__(self) -> Iterator[MetaValueEnum]:
 
        """Iterate over standard values."""
 
        return iter(self._stdvalues)
 

	
 
    def get(self,
 
            key: MetaValueEnum,
 
            default_key: Optional[MetaValueEnum]=None,
 
    ) -> Optional[MetaValueEnum]:
 
        """Return self[key], or a default fallback if that doesn't exist.
 

	
 
        default_key is another key to look up, *not* a default value to return.
 
        This helps ensure you always get a standard value.
 
        """
 
        try:
 
            return self[key]
 
        except KeyError:
 
            if default_key is None:
 
                return None
 
            else:
 
                return self[default_key]
 

	
 

	
 
### HOOK SUBCLASSES
 

	
 
class _PostingHook(TransactionHook, metaclass=abc.ABCMeta):
 
class TransactionHook(Hook[Transaction]):
 
    DIRECTIVE = Transaction
 
    TXN_DATE_RANGE: _GenericRange = _GenericRange(DEFAULT_START_DATE, DEFAULT_STOP_DATE)
 

	
 
    def __init_subclass__(cls) -> None:
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting'])
 

	
 
    def _run_on_txn(self, txn: Transaction) -> bool:
 
        """Check whether we should run on a given transaction
 

	
 
        This method implements our usual checks for whether or not a hook
 
        should run on a given transaction. It's here for subclasses to use in
 
        their own implementations. See _PostingHook below for an example.
 
        """
 
        return (
 
            txn.date in self.TXN_DATE_RANGE
 
            txn.flag != '!'
 
            and txn.date in self.TXN_DATE_RANGE
 
            and not data.is_opening_balance_txn(txn)
 
        )
 

	
 

	
 
class _PostingHook(TransactionHook, metaclass=abc.ABCMeta):
 
    def __init_subclass__(cls) -> None:
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting'])
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return True
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        if self._run_on_txn(txn):
 
            for post in data.Posting.from_txn(txn):
 
                if self._run_on_post(txn, post):
 
                    yield from self.post_run(txn, post)
 

	
 
    @abc.abstractmethod
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: ...
 

	
 

	
 
class _NormalizePostingMetadataHook(_PostingHook):
 
    """Base class to normalize posting metadata from an enum."""
 
    # This class provides basic functionality to filter postings, normalize
 
    # metadata values, and set default values.
 
    METADATA_KEY: MetaKey
 
    VALUES_ENUM: MetadataEnum
 

	
 
    def __init_subclass__(cls) -> None:
 
        super().__init_subclass__()
 
        cls.METADATA_KEY = cls.VALUES_ENUM.key
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['metadata', cls.METADATA_KEY])
 

	
 
    # If the posting does not specify METADATA_KEY, the hook calls
 
    # _default_value to get a default. This method should either return
 
    # a value string from METADATA_ENUM, or else raise InvalidMetadataError.
 
    # This base implementation does the latter.
 
    def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
 
        raise errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post)
 

	
conservancy_beancount/plugin/meta_repo_links.py
Show inline comments
...
 
@@ -34,36 +34,37 @@ from typing import (
 

	
 
class MetaRepoLinks(core.TransactionHook):
 
    HOOK_GROUPS = frozenset(['linkcheck'])
 
    LINK_METADATA = data.LINK_METADATA.difference('rt-id')
 
    PATH_PUNCT_RE = re.compile(r'[:/]')
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        repo_path = config.repository_path()
 
        if repo_path is None:
 
            raise errormod.ConfigurationError("no repository configured")
 
        self.repo_path = repo_path
 

	
 
    def _check_links(self,
 
                     meta: MutableMapping[MetaKey, MetaValue],
 
                     txn: Transaction,
 
                     post: Optional[Posting]=None,
 
    ) -> errormod.Iter:
 
        metadata = data.Metadata(meta)
 
        for key in self.LINK_METADATA:
 
            try:
 
                links = metadata.get_links(key)
 
            except TypeError:
 
                yield errormod.InvalidMetadataError(txn, key, meta[key], post)
 
            else:
 
                for link in links:
 
                    match = self.PATH_PUNCT_RE.search(link)
 
                    if match and match.group(0) == ':':
 
                        pass
 
                    elif not (self.repo_path / link).exists():
 
                        yield errormod.BrokenLinkError(txn, key, link)
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        yield from self._check_links(txn.meta, txn)
 
        for post in txn.postings:
 
            if post.meta is not None:
 
                yield from self._check_links(post.meta, txn, post)
 
        if self._run_on_txn(txn):
 
            yield from self._check_links(txn.meta, txn)
 
            for post in txn.postings:
 
                if post.meta is not None:
 
                    yield from self._check_links(post.meta, txn, post)
conservancy_beancount/plugin/meta_rt_links.py
Show inline comments
...
 
@@ -30,36 +30,37 @@ from typing import (
 
    Optional,
 
)
 

	
 
class MetaRTLinks(core.TransactionHook):
 
    HOOK_GROUPS = frozenset(['linkcheck', 'network', 'rt'])
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        rt_wrapper = config.rt_wrapper()
 
        if rt_wrapper is None:
 
            raise errormod.ConfigurationError("can't log in to RT")
 
        self.rt = rt_wrapper
 

	
 
    def _check_links(self,
 
                     meta: MutableMapping[MetaKey, MetaValue],
 
                     txn: Transaction,
 
                     post: Optional[Posting]=None,
 
    ) -> errormod.Iter:
 
        metadata = data.Metadata(meta)
 
        for key in data.LINK_METADATA:
 
            try:
 
                links = metadata.get_links(key)
 
            except TypeError:
 
                yield errormod.InvalidMetadataError(txn, key, meta[key], post)
 
            else:
 
                for link in links:
 
                    if not link.startswith('rt:'):
 
                        continue
 
                    parsed = self.rt.parse(link)
 
                    if parsed is None or not self.rt.exists(*parsed):
 
                        yield errormod.BrokenRTLinkError(txn, key, link, parsed)
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        yield from self._check_links(txn.meta, txn)
 
        for post in txn.postings:
 
            if post.meta is not None:
 
                yield from self._check_links(post.meta, txn, post)
 
        if self._run_on_txn(txn):
 
            yield from self._check_links(txn.meta, txn)
 
            for post in txn.postings:
 
                if post.meta is not None:
 
                    yield from self._check_links(post.meta, txn, post)
tests/test_meta_approval.py
Show inline comments
...
 
@@ -151,32 +151,39 @@ def test_approval_not_required_to_charge_credit_card(hook):
 
        (CREDITCARD_ACCOUNT, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_approval_not_required_to_pay_credit_card(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -25),
 
        (CREDITCARD_ACCOUNT, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('tax_implication,other_acct', [
 
    ('Bank-Transfer', 'Assets:Savings'),
 
    ('Chargeback', 'Income:Donations'),
 
])
 
def test_approval_not_required_for_asset_transfers(hook, tax_implication, other_acct):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -250, {'tax-implication': tax_implication}),
 
        (other_acct, 250),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_approval_required_for_partial_transfer(hook):
 
    # I'm not sure this ever comes up in reality, but just being thorough
 
    # out of an abundance of precaution.
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -250, {'tax-implication': 'Bank-Transfer'}),
 
        ('Assets:Savings', 225),
 
        ('Expenses:BankingFees', 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"Assets:Checking missing {}".format(TEST_KEY)}
 

	
 
def test_not_required_on_flagged(hook):
 
    txn = testutil.Transaction(flag='!', postings=[
 
        ('Assets:Checking', -25),
 
        ('Liabilities:Payable:Accounts', 25),
 
    ])
 
    assert not list(hook.run(txn))
tests/test_meta_invoice.py
Show inline comments
...
 
@@ -115,32 +115,43 @@ def test_invalid_values_on_transaction(hook, acct1, acct2, value):
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    expected = {
 
        MISSING_MSG(acct1),
 
        WRONG_TYPE_MSG(acct1, type(value).__name__),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == expected
 

	
 
@pytest.mark.parametrize('acct1,acct2', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
))
 
def test_missing_invoice(hook, acct1, acct2):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.OpeningBalance()
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('acct1,acct2', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
))
 
def test_not_required_on_flagged(acct1, acct2, hook):
 
    txn = testutil.Transaction(flag='!', postings=[
 
        (acct1, 25),
 
        (acct2, -25),
 
    ])
 
    assert not list(hook.run(txn))
tests/test_meta_payable_documentation.py
Show inline comments
...
 
@@ -142,32 +142,35 @@ def test_type_errors_reported_with_valid_txn_docs(hook, support_key, support_val
 
    }
 
    check(hook, expected, txn_meta=meta)
 

	
 
def test_paid_accts_not_checked(hook):
 
    txn = testutil.Transaction(postings=[
 
        (TEST_ACCT, 250),
 
        (OTHER_ACCT, -250),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('account', [
 
    'Assets:Bank:Checking',
 
    'Assets:Cash',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Expenses:BankingFees',
 
    'Income:Donations',
 
    'Liabilities:CreditCard',
 
    'Liabilities:Payable:Vacation',
 
    'Liabilities:UnearnedIncome:Donations',
 
])
 
def test_does_not_apply_to_other_accounts(hook, account):
 
    meta = seed_meta()
 
    check(hook, None, account, post_meta=meta)
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Liabilities:Payable:Accounts', -15),
 
        ('Liabilities:Payable:Vacation', -25),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), 40),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_not_required_on_flagged(hook):
 
    check(hook, None, txn_meta={'flag': '!'})
tests/test_meta_receipt.py
Show inline comments
...
 
@@ -320,32 +320,39 @@ def test_bad_type_check_id_on_txn(hook, test_acct, other_acct, 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,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': '!'})
tests/test_meta_receivable_documentation.py
Show inline comments
...
 
@@ -184,32 +184,36 @@ def test_received_invoices_not_checked(hook, invoice, meta_type):
 
))
 
def test_paid_invoices_not_checked(hook, invoice, other_acct):
 
    txn = testutil.Transaction(postings=[
 
        (TEST_ACCT, -250, {'invoice': invoice}),
 
        (other_acct, 250),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('account', [
 
    'Assets:Bank:Checking',
 
    'Assets:Cash',
 
    'Equity:OpeningBalance',
 
    'Expenses:BankingFees',
 
    'Liabilities:CreditCard',
 
    'Liabilities:Payable:Accounts',
 
])
 
def test_does_not_apply_to_other_accounts(hook, account):
 
    meta = seed_meta()
 
    check(hook, None, account, 'Expenses:Other', post_meta=meta)
 

	
 
def test_configuration_error_without_rt():
 
    config = testutil.TestConfig()
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_receivable_documentation.MetaReceivableDocumentation(config)
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Receivable:Accounts', 100),
 
        ('Assets:Receivable:Loans', 200),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), -300),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_not_required_on_flagged(hook):
 
    post_meta = seed_meta()
 
    check(hook, None, txn_meta={'flag': '!'}, post_meta=post_meta)
tests/test_meta_repo_links.py
Show inline comments
...
 
@@ -71,64 +71,74 @@ def test_good_txn_links(hook):
 
        ('Assets:Cash', 5),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_good_post_links(hook):
 
    meta = build_meta(None, GOOD_LINKS)
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -5, meta),
 
        ('Assets:Cash', 5),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_bad_txn_links(hook):
 
    meta = build_meta(None, BAD_LINKS)
 
    txn = testutil.Transaction(**meta, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {NOT_FOUND_MSG(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_bad_post_links(hook):
 
    meta = build_meta(None, BAD_LINKS)
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -5, meta.copy()),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {NOT_FOUND_MSG(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_flagged_txn_not_checked(hook):
 
    keys = iter(METADATA_KEYS)
 
    txn_meta = build_meta(keys, BAD_LINKS)
 
    txn_meta['flag'] = '!'
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        ('Income:Donations', -5, build_meta(keys, BAD_LINKS)),
 
        ('Assets:Checking', 5, build_meta(keys, BAD_LINKS)),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('value', testutil.NON_STRING_METADATA_VALUES)
 
def test_bad_metadata_type(hook, value):
 
    txn = testutil.Transaction(**{'check': value}, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {'transaction has wrong type of check: expected str but is a {}'.format(
 
        type(value).__name__,
 
    )}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('ext_doc', [
 
    'rt:123',
 
    'rt:456/789',
 
    'rt://ticket/23',
 
    'rt://ticket/34/attachments/567890',
 
])
 
def test_docs_outside_repository_not_checked(hook, ext_doc):
 
    txn = testutil.Transaction(
 
        receipt='{} {} {}'.format(GOOD_LINKS[0], ext_doc, BAD_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5),
 
            ('Assets:Cash', 5),
 
        ])
 
    expected = {NOT_FOUND_MSG('receipt', BAD_LINKS[1])}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_mixed_results(hook):
 
    txn = testutil.Transaction(
 
        approval='{} {}'.format(*GOOD_LINKS),
tests/test_meta_rt_links.py
Show inline comments
...
 
@@ -117,48 +117,58 @@ def test_bad_post_links(hook, link_source, format_error):
 
    ])
 
    expected = {format_error(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('value', testutil.NON_STRING_METADATA_VALUES)
 
def test_bad_metadata_type(hook, value):
 
    txn = testutil.Transaction(**{'rt-id': value}, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {'transaction has wrong type of rt-id: expected str but is a {}'.format(
 
        type(value).__name__,
 
    )}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('ext_doc', [
 
    'statement.txt',
 
    'https://example.org/',
 
])
 
def test_docs_outside_rt_not_checked(hook, ext_doc):
 
    txn = testutil.Transaction(
 
        receipt='{} {} {}'.format(GOOD_LINKS[0], ext_doc, MALFORMED_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5),
 
            ('Assets:Cash', 5),
 
        ])
 
    expected = {MALFORMED_MSG('receipt', MALFORMED_LINKS[1])}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_flagged_txn_not_checked(hook):
 
    txn_meta = build_meta(None, MALFORMED_LINKS)
 
    txn_meta['flag'] = '!'
 
    keys = iter(METADATA_KEYS)
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        ('Income:Donations', -5, build_meta(keys, MALFORMED_LINKS)),
 
        ('Assets:Checking', 5, build_meta(keys, NOT_FOUND_LINKS)),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_mixed_results(hook):
 
    txn = testutil.Transaction(
 
        approval='{} {}'.format(*GOOD_LINKS),
 
        contract='{} {}'.format(MALFORMED_LINKS[0], GOOD_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5, {'invoice': '{} {}'.format(*NOT_FOUND_LINKS)}),
 
            ('Assets:Cash', 5, {'statement': '{} {}'.format(GOOD_LINKS[0], MALFORMED_LINKS[1])}),
 
        ])
 
    expected = {
 
        MALFORMED_MSG('contract', MALFORMED_LINKS[0]),
 
        NOT_FOUND_MSG('invoice', NOT_FOUND_LINKS[0]),
 
        NOT_FOUND_MSG('invoice', NOT_FOUND_LINKS[1]),
 
        MALFORMED_MSG('statement', MALFORMED_LINKS[1]),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
0 comments (0 inline, 0 general)