libra/tests/test_manual_payment_requests_api.py
Padreug 7a4b3022c2 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>
2026-06-07 15:39:45 +02:00

307 lines
10 KiB
Python

"""Manual payment request flow — user asks for libra to pay them via a
non-Lightning route (cash, bank, etc.); admin approves or rejects.
Endpoints:
- `POST /libra/api/v1/manual-payment-request` (invoice key, user)
- `GET /libra/api/v1/manual-payment-requests` (invoice key, own only)
- `GET /libra/api/v1/manual-payment-requests/all` (super user, all)
- `POST /libra/api/v1/manual-payment-requests/{id}/approve` (super user)
- `POST /libra/api/v1/manual-payment-requests/{id}/reject` (super user)
The amount in the request body is in **satoshis** (no fiat conversion at this
endpoint — `CreateManualPaymentRequest` has `amount: int`).
Approve creates a Beancount payment entry:
DR Liabilities:Payable:User-{id} (zeroes libra's debt to the user)
CR Assets:Bitcoin:Lightning (cash leaves libra)
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_manual_payment_request,
reject_manual_payment_request,
submit_manual_payment_request,
)
# ---------------------------------------------------------------------------
# User-side submission
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_user_can_submit_manual_payment_request(
client, configured_user,
):
"""Submission returns 200 with a pending request and the user's id."""
user, wallet = configured_user
desc = f"Coffee reimbursement {uuid4().hex[:6]}"
result = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=50_000,
description=desc,
)
assert result.get("id"), f"missing id: {result}"
assert result.get("user_id") == user.id
assert result.get("amount") == 50_000
assert result.get("description") == desc
assert result.get("status") == "pending"
@pytest.mark.anyio
async def test_user_lists_own_manual_payment_requests(
client, configured_user,
):
"""The user-side listing returns the requests this user submitted."""
_, wallet = configured_user
tag = uuid4().hex[:6]
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=12_000,
description=f"list-test {tag}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests",
headers={"X-Api-Key": wallet.inkey},
)
assert r.status_code == 200, f"list: {r.status_code} {r.text}"
ids = [req.get("id") for req in r.json()]
assert submitted["id"] in ids, f"submitted request missing from listing: {ids}"
@pytest.mark.anyio
async def test_user_cannot_see_another_users_manual_payment_requests(
client, configured_user, configured_user_b,
):
"""User-side listing is scoped to the calling user, not all requests."""
user_a, wallet_a = configured_user
_, wallet_b = configured_user_b
submitted_a = await submit_manual_payment_request(
client,
wallet_inkey=wallet_a.inkey,
amount_sats=8_000,
description=f"A-private {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests",
headers={"X-Api-Key": wallet_b.inkey},
)
assert r.status_code == 200
user_ids = {req.get("user_id") for req in r.json()}
ids = [req.get("id") for req in r.json()]
assert submitted_a["id"] not in ids, (
f"user B saw user A's request: {submitted_a['id']} in {ids}"
)
assert user_a.id not in user_ids, (
f"user B's listing contained user A's id: {user_ids}"
)
# ---------------------------------------------------------------------------
# Admin listing
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_can_list_all_manual_payment_requests(
client, super_user_headers, configured_user, configured_user_b,
):
"""The admin listing returns requests from any user."""
_, wallet_a = configured_user
_, wallet_b = configured_user_b
a_req = await submit_manual_payment_request(
client,
wallet_inkey=wallet_a.inkey,
amount_sats=10_000,
description=f"A {uuid4().hex[:6]}",
)
b_req = await submit_manual_payment_request(
client,
wallet_inkey=wallet_b.inkey,
amount_sats=20_000,
description=f"B {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests/all",
headers=super_user_headers,
)
assert r.status_code == 200, f"admin list: {r.status_code} {r.text}"
ids = [req.get("id") for req in r.json()]
assert a_req["id"] in ids and b_req["id"] in ids, (
f"admin list missing entries: ids={ids}"
)
@pytest.mark.anyio
async def test_admin_listing_status_filter(
client, super_user_headers, configured_user,
):
"""`?status=pending` returns only the pending requests."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=5_000,
description=f"pending-filter {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests/all?status=pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"filtered list: {r.status_code} {r.text}"
statuses = {req.get("status") for req in r.json()}
assert statuses == {"pending"}, f"non-pending rows in filtered list: {statuses}"
assert submitted["id"] in [req.get("id") for req in r.json()]
@pytest.mark.anyio
async def test_non_super_user_cannot_list_all_requests(
client, configured_user,
):
"""Wallet admin-key of a non-super user fails the super-user check."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/manual-payment-requests/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()
# ---------------------------------------------------------------------------
# Approve / reject
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_can_reject_manual_payment_request(
client, super_user_headers, configured_user,
):
"""Reject flips status to 'rejected' and doesn't touch Beancount."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=3_500,
description=f"reject me {uuid4().hex[:6]}",
)
result = await reject_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
assert result.get("status") == "rejected"
@pytest.mark.anyio
async def test_rejecting_already_rejected_returns_400(
client, super_user_headers, configured_user,
):
"""The endpoint guards against double-decisions."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=4_000,
description=f"double reject {uuid4().hex[:6]}",
)
await reject_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "reject" in r.text.lower()
@pytest.mark.anyio
async def test_approve_unknown_request_returns_404(
client, super_user_headers,
):
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{uuid4().hex[:16]}/approve",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_approve(
client, configured_user,
):
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=2_000,
description=f"no approve for you {uuid4().hex[:6]}",
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_admin_can_approve_manual_payment_request(
client, super_user_headers, configured_user, standard_accounts,
# noqa: ARG001 (standard_accounts ensures Assets:Bitcoin:Lightning exists)
):
"""Approve creates a Beancount payment entry and flips status to
'approved'. Requires `Assets:Bitcoin:Lightning` to exist in libra's
local DB (provided by the `standard_accounts` fixture)."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=6_000,
description=f"approve me {uuid4().hex[:6]}",
)
result = await approve_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
assert result.get("status") == "approved"
assert result.get("id") == submitted["id"]
@pytest.mark.anyio
async def test_approving_already_approved_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""Idempotency guard: second approve on the same request is rejected
explicitly rather than producing a duplicate Beancount entry."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=7_500,
description=f"approve once {uuid4().hex[:6]}",
)
await approve_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "approve" in r.text.lower()