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
205
tests/test_lightning_api.py
Normal file
205
tests/test_lightning_api.py
Normal 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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue