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>
416 lines
14 KiB
Python
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"})
|