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:
Padreug 2026-06-07 09:47:09 +02:00
commit 7a4b3022c2
14 changed files with 3710 additions and 0 deletions

View file

@ -0,0 +1,211 @@
"""User-side expense submission flow — `POST /libra/api/v1/entries/expense`.
Covers:
- Submission lands as a pending entry, visible to the user, doesn't move
the cleared-only balance.
- Cross-user isolation user B can't see user A's entries.
- Permission gating, currency validation, missing user-wallet setup.
- Multiple submissions accumulate in the user journal listing.
Settlement, approval, and balance-after-approval are exercised in
`test_smoke.py` (one canonical path) and `test_balances_api.py` (the mixed
income+expense display scenario the user named).
"""
from uuid import uuid4
import pytest
from .helpers import (
create_account,
get_balance,
list_user_entries,
post_expense,
)
@pytest.mark.anyio
async def test_expense_creates_pending_entry_visible_in_user_journal(
client, configured_user, standard_accounts,
):
"""Submitting an expense creates a pending (`!`) entry the user can see
immediately. The cleared-only balance query is unchanged because pending
entries are excluded."""
_, wallet = configured_user
response = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="25.00",
currency="EUR",
description="Test groceries",
expense_account=standard_accounts["expense_food"]["name"],
)
assert response.get("id"), f"expected id in response, got {response}"
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
assert any(
"Test groceries" in (e.get("description") or "") for e in entries
), f"submitted expense missing from /entries/user: {entries}"
bal = await get_balance(client, wallet_inkey=wallet.inkey)
assert not bal.get("fiat_balances"), (
f"pending entry should not affect cleared balance, got {bal}"
)
@pytest.mark.anyio
async def test_user_cannot_see_other_users_entries(
client, configured_user, configured_user_b, standard_accounts,
):
"""User A submits an expense; user B's `/entries/user` listing is
scoped to B and never references A's user-id account fragment."""
user_a, wallet_a = configured_user
_, wallet_b = configured_user_b
await post_expense(
client,
wallet_inkey=wallet_a.inkey,
user_wallet_id=wallet_a.id,
amount="40.00",
currency="EUR",
description=f"A-private-{uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
listing_b = await list_user_entries(client, wallet_inkey=wallet_b.inkey)
a_short = user_a.id[:8]
for entry in listing_b.get("entries", []):
for posting in entry.get("postings", []):
assert a_short not in posting.get("account", ""), (
f"user B's listing leaked user A's account: {posting}"
)
@pytest.mark.anyio
async def test_expense_without_permission_returns_403(
client, super_user_headers, configured_user,
):
"""Submitting to an expense account the user has no `submit_expense`
permission on returns 403 with a permission-error detail."""
_, wallet = configured_user
# Fresh expense account that no permission was granted on.
new_account = await create_account(
client,
super_user_headers=super_user_headers,
name=f"Expenses:Test:Unguarded-{uuid4().hex[:6]}",
account_type="expense",
)
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Should be denied",
"amount": "10.00",
"currency": "EUR",
"expense_account": new_account["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "permission" in r.text.lower(), (
f"expected permission error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_expense_with_unknown_currency_returns_400(
client, configured_user, standard_accounts,
):
"""An unsupported currency is rejected with 400 before any Fava call."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Unknown currency",
"amount": "10.00",
"currency": "XYZ",
"expense_account": standard_accounts["expense_food"]["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "currency" in r.text.lower(), (
f"expected currency error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_expense_without_user_wallet_configured_returns_400(
client, libra_user, libra_wallet, standard_accounts, # noqa: ARG001 (libra_wallet ensures session-level setup)
):
"""A user whose own libra wallet isn't configured can't submit expenses.
`libra_user` (vs `configured_user`) skips the `PUT /user/wallet` step
on purpose so the precondition fires.
"""
_, wallet = libra_user
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Missing user wallet setup",
"amount": "10.00",
"currency": "EUR",
"expense_account": standard_accounts["expense_food"]["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "wallet" in r.text.lower(), (
f"expected wallet-config error, got {r.text!r}"
)
@pytest.mark.anyio
async def test_multiple_expenses_accumulate_in_user_journal(
client, configured_user, standard_accounts,
):
"""Each submission shows up in `/entries/user`; the listing's `total`
grows by exactly the number of submissions."""
_, wallet = configured_user
initial = await list_user_entries(client, wallet_inkey=wallet.inkey)
initial_total = initial.get("total", 0)
tag = uuid4().hex[:6]
descriptions = [f"Coffee-{tag}", f"Bread-{tag}", f"Vegetables-{tag}"]
for description in descriptions:
await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="7.50",
currency="EUR",
description=description,
expense_account=standard_accounts["expense_food"]["name"],
)
final = await list_user_entries(client, wallet_inkey=wallet.inkey)
final_total = final.get("total", 0)
assert final_total - initial_total == len(descriptions), (
f"expected total to grow by {len(descriptions)}, "
f"went from {initial_total} to {final_total}"
)
# Libra appends " (<amount> <currency>)" to entry descriptions, so check
# substring rather than exact match.
final_descs = [e.get("description") or "" for e in final.get("entries", [])]
for description in descriptions:
assert any(description in d for d in final_descs), (
f"missing {description} from journal listing: {final_descs}"
)