libra/tests/test_unit.py
Padreug 7a4b3022c2 Add integration test suite
113 passing tests + 3 skipped + 8 xfailed across 10 files, covering user
expense and income flow, admin receivable/revenue, settings + auth gates,
void/reject, manual payment requests, balance display, Lightning auth
paths, reconciliation API, and pure-function units. Runs against a real
Fava subprocess and full LNbits app via asgi_lifespan; the harness
captures the auth-flow / settings / env-var disciplines surfaced during
build-out (see tests/README.md and tests/conftest.py docstring).

Eight xfailed/skipped tests carry full implementations gated behind issues
#38, #39, #40 — they flip back on automatically when those land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00

416 lines
14 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")
AccountType = mdl.AccountType
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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"})