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

205
tests/test_lightning_api.py Normal file
View file

@ -0,0 +1,205 @@
"""Lightning payment flow — `POST /generate-payment-invoice` and
`POST /record-payment`.
- User has a balance owed to libra user generates an invoice on the libra
wallet user pays it `/record-payment` records the settlement entry.
## Coverage status
This file covers auth gates and error paths that don't require an active
Lightning backend. Tests that actually need invoice generation are skipped
because:
- The default `VoidWallet` 500s on any invoice operation.
- Switching to `FakeWallet` (via `settings.lnbits_backend_wallet_class`)
DOES enable invoice generation, but the LifespanManager teardown then
hangs indefinitely under anyio's TestRunner — some Lightning-side
background task doesn't unwind cleanly. Investigation deferred; the
auth gates + 404/400 error paths are what we can lock in for now.
The skipped tests carry full implementations so flipping them back on is
a one-line change once the teardown issue is resolved (or once we move to
a subprocess-based runner for the LN file).
"""
from uuid import uuid4
import pytest
from .helpers import (
list_user_entries,
post_receivable,
)
NEEDS_LIGHTNING_BACKEND = pytest.mark.skip(
reason="Tracked by libra/issues/40 — VoidWallet 500s, FakeWallet hangs the "
"LifespanManager teardown under anyio's TestRunner. Flip when resolved."
)
async def _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
amount="100.00",
):
"""Helper: create + (auto-cleared) receivable so the user has a balance
owed to libra. Returns the (user, wallet) pair."""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount=amount, currency="EUR",
description=f"Setup debt {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
# Force a Fava reload before downstream BQL balance reads (see #37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
return user, wallet
# ---------------------------------------------------------------------------
# /generate-payment-invoice
# ---------------------------------------------------------------------------
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_user_can_generate_invoice_for_own_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""User with a receivable generates an invoice on the libra wallet.
Response carries the bolt11 string and the libra wallet's inkey for
the client to poll payment status."""
_, wallet = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"amount": 50_000}, # 50k sats partial settlement
)
assert r.status_code == 200, f"generate-invoice: {r.status_code} {r.text}"
payload = r.json()
assert payload.get("payment_hash"), f"missing payment_hash: {payload}"
assert payload.get("payment_request"), f"missing bolt11 payment_request: {payload}"
assert payload.get("amount") == 50_000
assert payload.get("check_wallet_key"), f"missing check_wallet_key: {payload}"
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_super_user_can_generate_invoice_for_another_user(
client, super_user_headers, libra_wallet, configured_user, standard_accounts,
):
"""Admin generating an invoice on behalf of a user — uses the libra
wallet's admin key + body `user_id`. The endpoint actually requires
`wallet.wallet.user == super_user` (which is the libra wallet owner).
Generate-invoice is `require_invoice_key`-gated so we pass the libra
wallet's invoice key, and the user_id field opts into "for that user".
"""
user, _ = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": libra_wallet.inkey, "Content-type": "application/json"},
json={"amount": 30_000, "user_id": user.id},
)
assert r.status_code == 200, f"admin generate-invoice: {r.status_code} {r.text}"
assert r.json().get("payment_request"), "admin-generated invoice missing bolt11"
@pytest.mark.anyio
async def test_non_super_user_cannot_generate_invoice_for_another_user(
client, super_user_headers, configured_user, configured_user_b,
standard_accounts,
):
"""A regular user cannot pass `user_id` and have libra generate an
invoice on someone else's behalf — 403."""
user_a, _ = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
_, wallet_b = configured_user_b
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet_b.inkey, "Content-type": "application/json"},
json={"amount": 10_000, "user_id": user_a.id},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_generate_invoice_without_auth_returns_401(client):
"""Invoice-key auth required — no header → 401."""
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
json={"amount": 10_000},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
# ---------------------------------------------------------------------------
# /record-payment
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_record_payment_unknown_hash_returns_404(
client, configured_user,
):
"""Recording a payment hash that doesn't correspond to a real payment
in LNbits returns 404."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"payment_hash": "0" * 64},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "payment not found" in r.text.lower() or "payment" in r.text.lower()
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_record_payment_pending_invoice_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""A freshly-generated invoice that hasn't been paid yet is pending —
`/record-payment` must reject it with 400 rather than silently
recording a non-existent settlement."""
_, wallet = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
# Generate an invoice on the libra wallet.
gen = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"amount": 15_000},
)
assert gen.status_code == 200
payment_hash = gen.json()["payment_hash"]
# Try to record it before any payment lands.
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"payment_hash": payment_hash},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "not yet settled" in r.text.lower() or "pending" in r.text.lower(), (
f"expected pending/settled message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_record_payment_without_auth_returns_401(client):
r = await client.post(
"/libra/api/v1/record-payment",
json={"payment_hash": "abc"},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"