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>
205 lines
7.8 KiB
Python
205 lines
7.8 KiB
Python
"""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}"
|