_open_directive_exists hardcoded '^YYYY-MM-DD open ' (dash-only, 2-digit,
single-space), but Beancount's DATE token (parser/lexer.l) is
(17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ and inter-token whitespace is any
[ \t\r] run. So a validly-formatted existing Open written as '2024/3/5 open X'
or '2020-01-01 open X' escaped detection → duplicate Open appended →
bean-check rejects the file. Anchor on Beancount's actual date pattern and
[ \t]+ separators. Adds parametrized coverage for slash/single-digit/multi-
space/tab variants.
Found in a coherence pass over the Beancount source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
572 lines
20 KiB
Python
572 lines
20 KiB
Python
"""Pure-function unit tests — no harness, no Fava, no LNbits app.
|
|
|
|
Covers `libra.beancount_format`, `libra.account_utils`, `libra.core.validation`.
|
|
These modules have no external dependencies (stdlib + pydantic for models), so
|
|
they run fast and don't need fixtures.
|
|
|
|
The libra package is importable under either `lnbits.extensions.libra.*`
|
|
(default lnbits layout) or `libra.*` (LNBITS_EXTENSIONS_PATH override). The
|
|
`_module` helper tries both, mirroring the runtime-path discipline already
|
|
established in `conftest.py`.
|
|
"""
|
|
import importlib
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
|
|
def _module(name: str):
|
|
"""Import a libra submodule under whichever path the active LNbits layout
|
|
uses (default `lnbits.extensions.libra` or bare `libra`)."""
|
|
for prefix in ("lnbits.extensions.libra", "libra"):
|
|
try:
|
|
return importlib.import_module(f"{prefix}.{name}")
|
|
except ModuleNotFoundError:
|
|
continue
|
|
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
|
|
|
|
|
|
bf = _module("beancount_format")
|
|
au = _module("account_utils")
|
|
val = _module("core.validation")
|
|
mdl = _module("models")
|
|
fc = _module("fava_client")
|
|
AccountType = mdl.AccountType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fava_client._open_directive_exists — duplicate-account detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_open_directive_exists_matches_real_directive():
|
|
src = "2020-01-01 open Expenses:Vehicle:Gas\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
def test_open_directive_exists_matches_currency_constrained_and_metadata():
|
|
src = (
|
|
"2020-01-01 open Expenses:Vehicle:Gas EUR, SATS\n"
|
|
' added_by: "abc"\n'
|
|
)
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
def test_open_directive_exists_matches_inline_comment_without_space():
|
|
# Valid Beancount: the account token ends at ';'. The old (?:\\s|$) boundary
|
|
# missed this → duplicate Open written → bean-check breaks.
|
|
src = "2020-01-01 open Expenses:Vehicle:Gas;legacy-import\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
def test_open_directive_exists_ignores_name_inside_description():
|
|
# The name appears only inside another account's description metadata.
|
|
src = (
|
|
"2020-01-01 open Expenses:Notes\n"
|
|
' description: "remember to open Expenses:Vehicle:Gas next month"\n'
|
|
)
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
def test_open_directive_exists_ignores_comment_line():
|
|
src = "; TODO: open Expenses:Vehicle:Gas eventually\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
def test_open_directive_exists_does_not_match_longer_sibling():
|
|
src = "2020-01-01 open Expenses:Vehicle:GasStation\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
def test_open_directive_exists_does_not_match_deeper_child():
|
|
src = "2020-01-01 open Expenses:Vehicle:Gas:Premium\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"line",
|
|
[
|
|
"2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D
|
|
"2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D
|
|
"2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces
|
|
"2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators
|
|
"1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained
|
|
],
|
|
)
|
|
def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line):
|
|
# All of these are valid Beancount Open directives per lexer.l's DATE token
|
|
# and ignored inter-token whitespace; each must be detected as existing.
|
|
assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# beancount_format.sanitize_link
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("libra-abc123", "libra-abc123"),
|
|
("Invoice #123", "Invoice-123"),
|
|
("Test (pending)", "Test-pending"),
|
|
("a/b.c-d_e", "a/b.c-d_e"), # all permitted chars survive
|
|
("multiple spaces", "multiple-spaces"), # collapsed
|
|
("---leading-trailing---", "leading-trailing"),
|
|
("ascii_only", "ascii_only"),
|
|
],
|
|
)
|
|
def test_sanitize_link_strips_unsafe_chars(raw, expected):
|
|
assert bf.sanitize_link(raw) == expected
|
|
|
|
|
|
def test_sanitize_link_empty_string_stays_empty():
|
|
assert bf.sanitize_link("") == ""
|
|
|
|
|
|
def test_sanitize_link_unicode_replaced_with_hyphens():
|
|
# Non-ascii chars all collapse to single hyphens, stripped from edges.
|
|
result = bf.sanitize_link("café résumé")
|
|
assert all(ch in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/."
|
|
for ch in result), f"unsanitized chars in {result!r}"
|
|
assert not result.startswith("-")
|
|
assert not result.endswith("-")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# beancount_format.format_transaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_transaction_minimum_shape():
|
|
entry = bf.format_transaction(
|
|
date_val=date(2026, 6, 6),
|
|
flag="*",
|
|
narration="hello",
|
|
postings=[{"account": "Assets:Cash", "amount": "10 EUR"}],
|
|
)
|
|
# Fava's required fields.
|
|
assert entry["t"] == "Transaction"
|
|
assert entry["date"] == "2026-06-06"
|
|
assert entry["flag"] == "*"
|
|
assert entry["narration"] == "hello"
|
|
assert entry["payee"] == "" # empty string, not None
|
|
assert entry["tags"] == []
|
|
assert entry["links"] == []
|
|
assert entry["meta"] == {}
|
|
assert entry["postings"] == [{"account": "Assets:Cash", "amount": "10 EUR"}]
|
|
|
|
|
|
def test_format_transaction_optional_fields_are_passed_through():
|
|
entry = bf.format_transaction(
|
|
date_val=date(2026, 6, 6),
|
|
flag="!",
|
|
narration="pending lunch",
|
|
postings=[{"account": "Expenses:Food", "amount": "8 EUR"}],
|
|
payee="Bistro Local",
|
|
tags=["expense-entry"],
|
|
links=["libra-abc123"],
|
|
meta={"user-id": "abc12345"},
|
|
)
|
|
assert entry["flag"] == "!"
|
|
assert entry["payee"] == "Bistro Local"
|
|
assert entry["tags"] == ["expense-entry"]
|
|
assert entry["links"] == ["libra-abc123"]
|
|
assert entry["meta"] == {"user-id": "abc12345"}
|
|
|
|
|
|
def test_format_transaction_does_not_share_mutable_defaults():
|
|
"""Regression guard: passing `tags=None` shouldn't return the same list
|
|
every call (the classic Python mutable-default-argument trap)."""
|
|
a = bf.format_transaction(date(2026, 1, 1), "*", "a", [{"account": "X", "amount": "1 EUR"}])
|
|
b = bf.format_transaction(date(2026, 1, 2), "*", "b", [{"account": "Y", "amount": "1 EUR"}])
|
|
a["tags"].append("touched-a")
|
|
assert b["tags"] == [], "tags from one entry leaked into another"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# beancount_format.generate_entry_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_generate_entry_id_shape():
|
|
eid = bf.generate_entry_id()
|
|
assert len(eid) == 16
|
|
assert all(c in "0123456789abcdef" for c in eid), f"non-hex in {eid!r}"
|
|
|
|
|
|
def test_generate_entry_ids_are_unique():
|
|
ids = {bf.generate_entry_id() for _ in range(100)}
|
|
assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry identity contract — every libra-authored entry formatter must write
|
|
# `entry-id` metadata (the canonical id) and keep the user reference as its
|
|
# own sanitized link, never fused with the id.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_expense_entry_identity_contract():
|
|
entry = bf.format_expense_entry(
|
|
user_id="abc12345",
|
|
expense_account="Expenses:Food",
|
|
user_account="Liabilities:Payable:User-abc12345",
|
|
amount_sats=50000,
|
|
description="Groceries",
|
|
entry_date=date(2026, 6, 12),
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("46.50"),
|
|
reference="Invoice #123",
|
|
entry_id="deadbeef00000001",
|
|
)
|
|
assert entry["meta"]["entry-id"] == "deadbeef00000001"
|
|
assert "exp-deadbeef00000001" in entry["links"]
|
|
assert "Invoice-123" in entry["links"] # sanitized, standalone
|
|
|
|
|
|
def test_format_receivable_entry_identity_contract():
|
|
entry = bf.format_receivable_entry(
|
|
user_id="abc12345",
|
|
revenue_account="Income:Accommodation",
|
|
receivable_account="Assets:Receivable:User-abc12345",
|
|
amount_sats=100000,
|
|
description="2-night stay",
|
|
entry_date=date(2026, 6, 12),
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("93.00"),
|
|
reference="BOOKING/42",
|
|
entry_id="deadbeef00000002",
|
|
)
|
|
assert entry["meta"]["entry-id"] == "deadbeef00000002"
|
|
assert "rcv-deadbeef00000002" in entry["links"]
|
|
assert "BOOKING/42" in entry["links"]
|
|
|
|
|
|
def test_format_income_entry_identity_contract():
|
|
"""The production-bug shape: income + reference like '42-144'."""
|
|
entry = bf.format_income_entry(
|
|
user_id="abc12345",
|
|
user_account="Assets:Receivable:User-abc12345",
|
|
revenue_account="Income:MemberDuesContributions",
|
|
amount_sats=1112490,
|
|
description="2 Memberships",
|
|
entry_date=date(2026, 6, 12),
|
|
fiat_currency="USD",
|
|
fiat_amount=Decimal("700.00"),
|
|
reference="42-144",
|
|
entry_id="deadbeef00000003",
|
|
)
|
|
assert entry["meta"]["entry-id"] == "deadbeef00000003"
|
|
assert "inc-deadbeef00000003" in entry["links"]
|
|
assert "42-144" in entry["links"]
|
|
|
|
|
|
def test_format_revenue_entry_identity_contract():
|
|
entry = bf.format_revenue_entry(
|
|
payment_account="Assets:Cash",
|
|
revenue_account="Income:Sales",
|
|
amount_sats=100000,
|
|
description="Product sale",
|
|
entry_date=date(2026, 6, 12),
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("50.00"),
|
|
reference="Till receipt 9",
|
|
entry_id="deadbeef00000004",
|
|
)
|
|
assert entry["meta"]["entry-id"] == "deadbeef00000004"
|
|
assert "Till-receipt-9" in entry["links"] # sanitized
|
|
|
|
|
|
def test_format_revenue_entry_generates_entry_id_when_absent():
|
|
entry = bf.format_revenue_entry(
|
|
payment_account="Assets:Cash",
|
|
revenue_account="Income:Sales",
|
|
amount_sats=100000,
|
|
description="Product sale",
|
|
entry_date=date(2026, 6, 12),
|
|
)
|
|
eid = entry["meta"]["entry-id"]
|
|
assert len(eid) == 16 and all(c in "0123456789abcdef" for c in eid)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.format_hierarchical_account_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_hierarchical_simple_asset():
|
|
assert au.format_hierarchical_account_name(AccountType.ASSET, "Cash") == "Assets:Cash"
|
|
|
|
|
|
def test_format_hierarchical_user_specific_uses_8_char_prefix():
|
|
full_user_id = "af983632aabbccddeeff00112233445566"
|
|
name = au.format_hierarchical_account_name(
|
|
AccountType.ASSET, "Accounts Receivable", user_id=full_user_id,
|
|
)
|
|
assert name == "Assets:Receivable:User-af983632" # 8-char prefix, "Accounts " stripped
|
|
|
|
|
|
def test_format_hierarchical_ampersand_expands_to_colon():
|
|
"""`Food & Supplies` is a legacy display form; it becomes a hierarchy."""
|
|
name = au.format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies")
|
|
assert name == "Expenses:Food:Supplies"
|
|
|
|
|
|
def test_format_hierarchical_revenue_uses_income_root():
|
|
"""Beancount uses `Income`, not `Revenue` — the mapping is in
|
|
`ACCOUNT_TYPE_ROOTS`."""
|
|
name = au.format_hierarchical_account_name(AccountType.REVENUE, "Accommodation")
|
|
assert name == "Income:Accommodation"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.parse_legacy_account_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_legacy_with_user_suffix():
|
|
assert au.parse_legacy_account_name("Accounts Receivable - af983632") == (
|
|
"Accounts Receivable", "af983632",
|
|
)
|
|
|
|
|
|
def test_parse_legacy_without_user_suffix():
|
|
assert au.parse_legacy_account_name("Cash") == ("Cash", None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.format_account_display_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("hierarchical", "expected"),
|
|
[
|
|
("Assets:Receivable:User-af983632", "Accounts Receivable - af983632"),
|
|
("Liabilities:Payable:User-cafebabe", "Accounts Payable - cafebabe"),
|
|
("Expenses:Food:Supplies", "Food & Supplies"),
|
|
("Assets:Cash", "Cash"),
|
|
("Assets", "Assets"), # too short — passes through
|
|
],
|
|
)
|
|
def test_format_account_display_name(hierarchical, expected):
|
|
assert au.format_account_display_name(hierarchical) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.get_account_type_from_hierarchical
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected_type"),
|
|
[
|
|
("Assets:Cash", AccountType.ASSET),
|
|
("Liabilities:Payable:User-x", AccountType.LIABILITY),
|
|
("Equity:User-x", AccountType.EQUITY),
|
|
("Income:Accommodation", AccountType.REVENUE),
|
|
("Expenses:Food", AccountType.EXPENSE),
|
|
],
|
|
)
|
|
def test_get_account_type_from_hierarchical(name, expected_type):
|
|
assert au.get_account_type_from_hierarchical(name) == expected_type
|
|
|
|
|
|
def test_get_account_type_unknown_root_returns_none():
|
|
assert au.get_account_type_from_hierarchical("Other:Random") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.migrate_account_name — round-trip legacy → hierarchical
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_migrate_account_name_receivable():
|
|
out = au.migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET)
|
|
assert out == "Assets:Receivable:User-af983632"
|
|
|
|
|
|
def test_migrate_account_name_expense_with_ampersand():
|
|
assert au.migrate_account_name("Food & Supplies", AccountType.EXPENSE) == (
|
|
"Expenses:Food:Supplies"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — validate_journal_entry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_journal_entry_balanced_passes():
|
|
val.validate_journal_entry(
|
|
{"id": "x"},
|
|
[
|
|
{"account_id": "a", "amount": 100},
|
|
{"account_id": "b", "amount": -100},
|
|
],
|
|
)
|
|
|
|
|
|
def test_validate_journal_entry_unbalanced_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_journal_entry(
|
|
{"id": "x"},
|
|
[
|
|
{"account_id": "a", "amount": 100},
|
|
{"account_id": "b", "amount": -50},
|
|
],
|
|
)
|
|
assert "not balanced" in str(exc.value)
|
|
|
|
|
|
def test_validate_journal_entry_single_line_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_journal_entry(
|
|
{"id": "x"},
|
|
[{"account_id": "a", "amount": 100}],
|
|
)
|
|
assert "at least 2 lines" in str(exc.value)
|
|
|
|
|
|
def test_validate_journal_entry_zero_amount_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_journal_entry(
|
|
{"id": "x"},
|
|
[
|
|
{"account_id": "a", "amount": 0},
|
|
{"account_id": "b", "amount": 0},
|
|
],
|
|
)
|
|
assert "amount = 0" in str(exc.value)
|
|
|
|
|
|
def test_validate_journal_entry_missing_account_id_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_journal_entry(
|
|
{"id": "x"},
|
|
[
|
|
{"amount": 100},
|
|
{"account_id": "b", "amount": -100},
|
|
],
|
|
)
|
|
assert "missing account_id" in str(exc.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — validate_balance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_balance_exact_match_passes():
|
|
val.validate_balance("acct", expected_balance_sats=1000, actual_balance_sats=1000)
|
|
|
|
|
|
def test_validate_balance_within_tolerance_passes():
|
|
val.validate_balance(
|
|
"acct", expected_balance_sats=1000, actual_balance_sats=1005, tolerance_sats=10,
|
|
)
|
|
|
|
|
|
def test_validate_balance_outside_tolerance_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_balance(
|
|
"acct", expected_balance_sats=1000, actual_balance_sats=1100, tolerance_sats=10,
|
|
)
|
|
assert "Balance assertion failed" in str(exc.value)
|
|
|
|
|
|
def test_validate_balance_fiat_mismatch_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_balance(
|
|
"acct",
|
|
expected_balance_sats=1000,
|
|
actual_balance_sats=1000,
|
|
expected_balance_fiat=Decimal("100.00"),
|
|
actual_balance_fiat=Decimal("99.50"),
|
|
tolerance_fiat=Decimal("0.10"),
|
|
fiat_currency="EUR",
|
|
)
|
|
assert "Fiat balance" in str(exc.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — entry-specific validators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_receivable_entry_positive_revenue_passes():
|
|
val.validate_receivable_entry("u", amount=100, revenue_account_type="revenue")
|
|
|
|
|
|
def test_validate_receivable_entry_zero_amount_raises():
|
|
with pytest.raises(val.ValidationError):
|
|
val.validate_receivable_entry("u", amount=0, revenue_account_type="revenue")
|
|
|
|
|
|
def test_validate_receivable_entry_wrong_account_type_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_receivable_entry("u", amount=100, revenue_account_type="expense")
|
|
assert "revenue account" in str(exc.value)
|
|
|
|
|
|
def test_validate_expense_entry_non_equity_requires_expense_account():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_expense_entry(
|
|
"u", amount=100, expense_account_type="asset", is_equity=False,
|
|
)
|
|
assert "expense account" in str(exc.value)
|
|
|
|
|
|
def test_validate_expense_entry_equity_allows_non_expense_account():
|
|
"""Equity contributions bypass the expense-account requirement."""
|
|
val.validate_expense_entry(
|
|
"u", amount=100, expense_account_type="equity", is_equity=True,
|
|
)
|
|
|
|
|
|
def test_validate_payment_entry_negative_raises():
|
|
with pytest.raises(val.ValidationError):
|
|
val.validate_payment_entry("u", amount=-1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — validate_metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_metadata_required_keys_missing_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_metadata({"foo": 1}, required_keys=["bar", "baz"])
|
|
assert "bar" in str(exc.value) and "baz" in str(exc.value)
|
|
|
|
|
|
def test_validate_metadata_fiat_currency_without_amount_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_metadata({"fiat_currency": "EUR"})
|
|
assert "both be present or both absent" in str(exc.value)
|
|
|
|
|
|
def test_validate_metadata_fiat_amount_without_currency_raises():
|
|
with pytest.raises(val.ValidationError):
|
|
val.validate_metadata({"fiat_amount": "10.00"})
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
|
|
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
|
|
strict=True,
|
|
)
|
|
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
|
|
assert "Invalid fiat_amount" in str(exc.value)
|
|
|
|
|
|
def test_validate_metadata_both_present_passes():
|
|
val.validate_metadata({"fiat_amount": "100.50", "fiat_currency": "EUR"})
|
|
|
|
|
|
def test_validate_metadata_neither_present_passes():
|
|
val.validate_metadata({"source": "api"})
|