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
307
tests/test_manual_payment_requests_api.py
Normal file
307
tests/test_manual_payment_requests_api.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue