libra/tests/test_unit.py
Padreug 3adb3d356a fix(accounts): match Beancount's DATE grammar in duplicate detection (libra-#48)
_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>
2026-06-17 10:27:18 +02:00

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"})