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>
This commit is contained in:
parent
9c88993c13
commit
7a4b3022c2
14 changed files with 3710 additions and 0 deletions
452
tests/test_balances_api.py
Normal file
452
tests/test_balances_api.py
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
"""Balance display tests — the user-named "mixture of income and expenses
|
||||
displayed correctly" scenario.
|
||||
|
||||
The balance API returns figures from libra's perspective:
|
||||
- Negative `fiat_balances[CCY]` → libra owes the user
|
||||
- Positive `fiat_balances[CCY]` → user owes libra
|
||||
- Sum across Payable + Receivable + Credit per currency
|
||||
(Credit added per libra-#41: overpayment lands as a liability that
|
||||
libra owes the user going forward, naturally subtracting from net.)
|
||||
|
||||
Lifetime totals (`total_expenses_fiat`, `total_income_fiat`) are kept
|
||||
separate per the `models.py:93` comment — "original entries only; not net of
|
||||
reconciliation" — so they don't reflect settlement activity or credit.
|
||||
|
||||
Excluded from the balance query: pending entries (flag `!`), voided entries
|
||||
(tag `voided`). Tested explicitly here so the contract is locked in.
|
||||
|
||||
Note: this file does NOT cover post-settlement netting; that's blocked on
|
||||
issue #33 (settlement leaves both per-user accounts non-zero) and lives in
|
||||
the settlement test file.
|
||||
"""
|
||||
import importlib
|
||||
from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import (
|
||||
approve_entry,
|
||||
get_all_balances,
|
||||
get_balance,
|
||||
list_user_entries,
|
||||
post_expense,
|
||||
post_income,
|
||||
post_receivable,
|
||||
reject_entry,
|
||||
)
|
||||
|
||||
|
||||
def _libra_module(submodule: str):
|
||||
"""Import a libra submodule via whichever path the harness uses (matches
|
||||
the resolver in conftest.py)."""
|
||||
for prefix in ("lnbits.extensions.libra", "libra"):
|
||||
try:
|
||||
return importlib.import_module(f"{prefix}.{submodule}")
|
||||
except ModuleNotFoundError:
|
||||
continue
|
||||
raise ModuleNotFoundError(f"libra.{submodule}")
|
||||
|
||||
|
||||
async def _approve_and_refresh(client, wallet, super_user_headers, entry_id):
|
||||
"""Approve a pending entry then force a fresh Fava read.
|
||||
|
||||
Workaround for libra issue #37 — BQL balance reads can lag add_entry
|
||||
by a few ms. The user-journal endpoint forces a Fava reload.
|
||||
"""
|
||||
await approve_entry(
|
||||
client, super_user_headers=super_user_headers, entry_id=entry_id,
|
||||
)
|
||||
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-direction balances
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pure_expense_balance_is_negative(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""User submits a single expense → libra owes them → balance < 0 EUR."""
|
||||
_, wallet = configured_user
|
||||
entry = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="40.00", currency="EUR",
|
||||
description=f"Pure expense {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, entry["id"])
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert float(eur) == pytest.approx(-40.0), (
|
||||
f"expected -40 EUR (libra owes user), got {eur}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pure_income_balance_is_positive(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""User submits a single income → user owes libra → balance > 0 EUR.
|
||||
|
||||
`/entries/income` records that the user collected money on libra's
|
||||
behalf, creating an `Assets:Receivable:User-{id}` debit until they
|
||||
settle by handing the cash over.
|
||||
"""
|
||||
_, wallet = configured_user
|
||||
entry = await post_income(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount="120.00", currency="EUR",
|
||||
description=f"Pure income {uuid4().hex[:6]}",
|
||||
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, entry["id"])
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert float(eur) == pytest.approx(120.0), (
|
||||
f"expected +120 EUR (user owes libra), got {eur}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mixed direction — the headline scenario
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mixed_expense_and_income_nets_correctly(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""User has 50 EUR expense + 120 EUR income (both approved) → net
|
||||
balance is +70 EUR (user owes libra 70).
|
||||
|
||||
This is the user's headline "displayed correctly" scenario — the
|
||||
Payable and Receivable rows sum into one EUR figure.
|
||||
"""
|
||||
_, wallet = configured_user
|
||||
|
||||
expense = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="50.00", currency="EUR",
|
||||
description=f"Coffee {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
income = await post_income(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount="120.00", currency="EUR",
|
||||
description=f"Cash deposit {uuid4().hex[:6]}",
|
||||
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, income["id"])
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert float(eur) == pytest.approx(70.0), (
|
||||
f"expected +70 EUR (120 - 50, user-owes-libra), got {eur} from {balance}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mixed_expense_and_receivable_nets_correctly(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""Admin-recorded receivable + user-submitted expense should net the
|
||||
same way as expense + income — both push the receivable side."""
|
||||
user, wallet = configured_user
|
||||
|
||||
await post_receivable(
|
||||
client,
|
||||
super_user_headers=super_user_headers,
|
||||
user_id=user.id,
|
||||
amount="80.00", currency="EUR",
|
||||
description=f"Admin debt {uuid4().hex[:6]}",
|
||||
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||
)
|
||||
expense = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="30.00", currency="EUR",
|
||||
description=f"User expense {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert float(eur) == pytest.approx(50.0), (
|
||||
f"expected +50 EUR (80 - 30), got {eur} from {balance}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifetime totals (separate from net balance)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lifetime_totals_track_originals_not_net(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""`total_expenses_fiat` and `total_income_fiat` track originally-entered
|
||||
amounts, not net obligation — see the `models.py:93` invariant. Even
|
||||
after partial-direction submissions, the totals should equal the gross.
|
||||
"""
|
||||
_, wallet = configured_user
|
||||
|
||||
expense = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="45.00", currency="EUR",
|
||||
description=f"e1 {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
income = await post_income(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount="80.00", currency="EUR",
|
||||
description=f"i1 {uuid4().hex[:6]}",
|
||||
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, income["id"])
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
exp_eur = balance.get("total_expenses_fiat", {}).get("EUR", 0)
|
||||
inc_eur = balance.get("total_income_fiat", {}).get("EUR", 0)
|
||||
assert float(exp_eur) == pytest.approx(45.0), (
|
||||
f"total_expenses_fiat should be gross 45, got {exp_eur}"
|
||||
)
|
||||
assert float(inc_eur) == pytest.approx(80.0), (
|
||||
f"total_income_fiat should be gross 80, got {inc_eur}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exclusions — pending and voided
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pending_entries_excluded_from_balance(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""Two expenses submitted, only one approved → only the approved one
|
||||
moves the balance."""
|
||||
_, wallet = configured_user
|
||||
|
||||
approved = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="25.00", currency="EUR",
|
||||
description=f"approved-only {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
# Submit a second expense but leave it pending.
|
||||
await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="1000.00", currency="EUR",
|
||||
description=f"pending-not-counted {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_supplies"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, approved["id"])
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert float(eur) == pytest.approx(-25.0), (
|
||||
f"only approved expense should count; pending 1000 must be excluded. "
|
||||
f"got {eur}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_voided_entries_excluded_from_balance(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""A voided entry stops contributing to the balance the moment it's
|
||||
rejected — verified by submitting then rejecting and confirming the
|
||||
balance is what it would be without that entry."""
|
||||
_, wallet = configured_user
|
||||
|
||||
keep = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="35.00", currency="EUR",
|
||||
description=f"keep {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
rejected = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="500.00", currency="EUR",
|
||||
description=f"will-be-voided {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet, super_user_headers, keep["id"])
|
||||
await reject_entry(
|
||||
client, super_user_headers=super_user_headers, entry_id=rejected["id"],
|
||||
)
|
||||
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert float(eur) == pytest.approx(-35.0), (
|
||||
f"voided 500 must not contribute; only the 35 EUR keeper. got {eur}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin /balances/all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_balances_all_includes_users_with_obligations(
|
||||
client, super_user_headers, configured_user, configured_user_b,
|
||||
standard_accounts,
|
||||
):
|
||||
"""`/balances/all` returns one row per user that has any Payable or
|
||||
Receivable activity. Two users → two rows after both submit + approve.
|
||||
"""
|
||||
user_a, wallet_a = configured_user
|
||||
user_b, wallet_b = configured_user_b
|
||||
|
||||
a_entry = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet_a.inkey,
|
||||
user_wallet_id=wallet_a.id,
|
||||
amount="60.00", currency="EUR",
|
||||
description=f"A-bal {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
b_entry = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet_b.inkey,
|
||||
user_wallet_id=wallet_b.id,
|
||||
amount="90.00", currency="EUR",
|
||||
description=f"B-bal {uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
await _approve_and_refresh(client, wallet_a, super_user_headers, a_entry["id"])
|
||||
await _approve_and_refresh(client, wallet_b, super_user_headers, b_entry["id"])
|
||||
|
||||
rows = await get_all_balances(client, super_user_headers=super_user_headers)
|
||||
by_id = {r.get("user_id")[:8]: r for r in rows if r.get("user_id")}
|
||||
assert user_a.id[:8] in by_id, f"user A missing from /balances/all"
|
||||
assert user_b.id[:8] in by_id, f"user B missing from /balances/all"
|
||||
|
||||
a_eur = by_id[user_a.id[:8]].get("fiat_balances", {}).get("EUR")
|
||||
b_eur = by_id[user_b.id[:8]].get("fiat_balances", {}).get("EUR")
|
||||
assert float(a_eur) == pytest.approx(-60.0), (
|
||||
f"user A EUR balance wrong in /balances/all: {a_eur}"
|
||||
)
|
||||
assert float(b_eur) == pytest.approx(-90.0), (
|
||||
f"user B EUR balance wrong in /balances/all: {b_eur}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_non_super_user_cannot_get_all_balances(
|
||||
client, configured_user,
|
||||
):
|
||||
"""`/balances/all` is admin-only — regular user wallet admin-key 403s."""
|
||||
_, wallet = configured_user
|
||||
r = await client.get(
|
||||
"/libra/api/v1/balances/all",
|
||||
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
|
||||
assert "super" in r.text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credit balance — libra-#41
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_credit_balance_subtracts_from_net(
|
||||
client, configured_user,
|
||||
):
|
||||
"""A user-credit balance on `Liabilities:Credit:User-X` flows into the
|
||||
displayed net so the user-facing balance is always honest about what
|
||||
libra owes them.
|
||||
|
||||
`#41` will land the settlement-side overflow logic that writes credit
|
||||
automatically. This test pre-creates the credit account and posts a
|
||||
balanced credit-bearing transaction directly via Fava so we can lock
|
||||
in the BQL-side behaviour (`get_user_balance_bql` includes the Credit
|
||||
namespace alongside Payable + Receivable) ahead of the settlement
|
||||
endpoint changes in #14.
|
||||
"""
|
||||
user, wallet = configured_user
|
||||
|
||||
fava_client_mod = _libra_module("fava_client")
|
||||
fava = fava_client_mod.get_fava_client()
|
||||
|
||||
# Open the per-user credit account in Beancount. The settlement endpoint
|
||||
# will do this via `get_or_create_user_account` when #14 lands.
|
||||
credit_account = f"Liabilities:Credit:User-{user.id[:8]}"
|
||||
await fava.add_account(credit_account, currencies=["EUR", "SATS"])
|
||||
|
||||
# Manually post a balanced entry mimicking what the future settlement
|
||||
# overflow leg looks like in isolation:
|
||||
# DR Assets:Cash +30 EUR (libra receives cash)
|
||||
# CR Liabilities:Credit -30 EUR (libra owes user that 30 going forward)
|
||||
tag = uuid4().hex[:6]
|
||||
beancount_format = _libra_module("beancount_format")
|
||||
entry = beancount_format.format_transaction(
|
||||
date_val=date.today(),
|
||||
flag="*",
|
||||
narration=f"Credit-balance test {tag}",
|
||||
postings=[
|
||||
{"account": "Assets:Cash", "amount": "30.00 EUR"},
|
||||
{"account": credit_account, "amount": "-30.00 EUR"},
|
||||
],
|
||||
tags=["credit-test"],
|
||||
links=[f"credit-test-{tag}"],
|
||||
meta={"user-id": user.id, "source": "test"},
|
||||
)
|
||||
await fava.add_entry(entry)
|
||||
|
||||
# Force a fresh Fava read before the BQL balance query (libra-#37).
|
||||
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||
|
||||
# The user's EUR balance should now read -30 (libra owes user 30 via
|
||||
# credit). Without the BQL change, this would read 0 because the query
|
||||
# would skip the Credit namespace entirely.
|
||||
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||
assert eur is not None, f"missing EUR in fiat_balances: {balance}"
|
||||
assert float(eur) == pytest.approx(-30.0), (
|
||||
f"expected -30 EUR (libra owes user via credit), got {eur} from {balance}"
|
||||
)
|
||||
|
||||
# The accounts breakdown should surface the credit row so UIs can render
|
||||
# it as a distinct line item per #41's display contract. `accounts` (the
|
||||
# legacy field on UserBalance) stays empty for back-compat; the new
|
||||
# `account_balances` field carries the BQL per-account breakdown.
|
||||
account_balances = balance.get("account_balances", [])
|
||||
credit_rows = [
|
||||
a for a in account_balances if "Credit" in (a.get("account") or "")
|
||||
]
|
||||
assert credit_rows, (
|
||||
f"credit account missing from breakdown — UI can't render 'You have "
|
||||
f"30 EUR credit' line item. account_balances: {account_balances}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue