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>
392 lines
12 KiB
Python
392 lines
12 KiB
Python
"""Convenience helpers for Libra integration tests.
|
|
|
|
Wrap the most common multi-step flows so each test reads as a sequence of
|
|
intentions rather than as a sequence of HTTP calls. Every helper returns the
|
|
parsed JSON response and asserts a successful status code — tests that want
|
|
to assert on failures should call the endpoint directly.
|
|
|
|
All amounts are passed as Decimal (or numeric string). Currency goes as a
|
|
separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntry`
|
|
/ `SettleReceivable` / `PayUser` etc., which all carry `amount: Decimal` and
|
|
`currency: Optional[str]` independently.
|
|
"""
|
|
from decimal import Decimal
|
|
from typing import Any, Optional, Union
|
|
|
|
from httpx import AsyncClient
|
|
|
|
Amount = Union[Decimal, int, float, str]
|
|
|
|
|
|
def _amount(value: Amount) -> str:
|
|
"""Coerce amount to a JSON-serialisable string Pydantic will parse as Decimal."""
|
|
return str(value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup — libra wallet + per-user wallet + accounts + permissions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def configure_libra_wallet(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
libra_wallet_id: str,
|
|
) -> dict:
|
|
"""Super user sets the libra wallet (required before any entry endpoint works)."""
|
|
r = await client.put(
|
|
"/libra/api/v1/settings",
|
|
headers=super_user_headers,
|
|
json={"libra_wallet_id": libra_wallet_id},
|
|
)
|
|
assert r.status_code == 200, f"configure_libra_wallet failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def configure_user_wallet(
|
|
client: AsyncClient,
|
|
*,
|
|
wallet_inkey: str,
|
|
user_wallet_id: str,
|
|
) -> dict:
|
|
"""User sets their personal wallet (required before they can submit entries)."""
|
|
r = await client.put(
|
|
"/libra/api/v1/user/wallet",
|
|
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
|
|
json={"user_wallet_id": user_wallet_id},
|
|
)
|
|
assert r.status_code == 200, f"configure_user_wallet failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def create_account(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
name: str,
|
|
account_type: str,
|
|
description: Optional[str] = None,
|
|
) -> dict:
|
|
"""Super user creates an account in the libra local DB.
|
|
|
|
`account_type` is one of "asset", "liability", "equity", "revenue", "expense".
|
|
"""
|
|
r = await client.post(
|
|
"/libra/api/v1/accounts",
|
|
headers=super_user_headers,
|
|
json={
|
|
"name": name,
|
|
"account_type": account_type,
|
|
"description": description,
|
|
},
|
|
)
|
|
assert r.status_code == 201, f"create_account failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def grant_permission(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
user_id: str,
|
|
account_id: str,
|
|
permission_type: str = "submit_expense",
|
|
) -> dict:
|
|
r = await client.post(
|
|
"/libra/api/v1/admin/permissions",
|
|
headers=super_user_headers,
|
|
json={
|
|
"user_id": user_id,
|
|
"account_id": account_id,
|
|
"permission_type": permission_type,
|
|
},
|
|
)
|
|
assert r.status_code == 201, f"grant_permission failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entries — user side
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def post_expense(
|
|
client: AsyncClient,
|
|
*,
|
|
wallet_inkey: str,
|
|
user_wallet_id: str,
|
|
amount: Amount,
|
|
description: str,
|
|
expense_account: str,
|
|
currency: Optional[str] = "EUR",
|
|
is_equity: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""User submits an expense — creates Liability (libra owes user) or Equity contribution.
|
|
|
|
Returns the created JournalEntry payload.
|
|
"""
|
|
r = await client.post(
|
|
"/libra/api/v1/entries/expense",
|
|
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
|
|
json={
|
|
"description": description,
|
|
"amount": _amount(amount),
|
|
"expense_account": expense_account,
|
|
"user_wallet": user_wallet_id,
|
|
"currency": currency,
|
|
"is_equity": is_equity,
|
|
},
|
|
)
|
|
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def post_income(
|
|
client: AsyncClient,
|
|
*,
|
|
wallet_inkey: str,
|
|
amount: Amount,
|
|
description: str,
|
|
revenue_account: str,
|
|
currency: str = "EUR",
|
|
) -> dict[str, Any]:
|
|
"""User submits income on libra's behalf — creates Receivable (user owes libra)."""
|
|
r = await client.post(
|
|
"/libra/api/v1/entries/income",
|
|
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
|
|
json={
|
|
"description": description,
|
|
"amount": _amount(amount),
|
|
"revenue_account": revenue_account,
|
|
"currency": currency,
|
|
},
|
|
)
|
|
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[dict]:
|
|
r = await client.get(
|
|
"/libra/api/v1/entries/user",
|
|
headers={"X-Api-Key": wallet_inkey},
|
|
)
|
|
assert r.status_code == 200, f"list_user_entries failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entries — admin side
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def post_receivable(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
user_id: str,
|
|
amount: Amount,
|
|
description: str,
|
|
revenue_account: str,
|
|
currency: str = "EUR",
|
|
) -> dict[str, Any]:
|
|
"""Admin records a receivable — user owes libra."""
|
|
r = await client.post(
|
|
"/libra/api/v1/entries/receivable",
|
|
headers=super_user_headers,
|
|
json={
|
|
"user_id": user_id,
|
|
"amount": _amount(amount),
|
|
"description": description,
|
|
"revenue_account": revenue_account,
|
|
"currency": currency,
|
|
},
|
|
)
|
|
assert r.status_code == 201, f"post_receivable failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def post_revenue(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
amount: Amount,
|
|
description: str,
|
|
revenue_account: str,
|
|
payment_method_account: str,
|
|
currency: str = "EUR",
|
|
) -> dict[str, Any]:
|
|
r = await client.post(
|
|
"/libra/api/v1/entries/revenue",
|
|
headers=super_user_headers,
|
|
json={
|
|
"amount": _amount(amount),
|
|
"description": description,
|
|
"revenue_account": revenue_account,
|
|
"payment_method_account": payment_method_account,
|
|
"currency": currency,
|
|
},
|
|
)
|
|
assert r.status_code == 201, f"post_revenue failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Balances
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def get_balance(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]:
|
|
"""Calling user's balance (or libra total if invoked by super user)."""
|
|
r = await client.get(
|
|
"/libra/api/v1/balance",
|
|
headers={"X-Api-Key": wallet_inkey},
|
|
)
|
|
assert r.status_code == 200, f"get_balance failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def get_all_balances(
|
|
client: AsyncClient, *, super_user_headers: dict
|
|
) -> list[dict]:
|
|
r = await client.get(
|
|
"/libra/api/v1/balances/all",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"get_all_balances failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settlement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def settle_receivable(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
user_id: str,
|
|
amount: Amount,
|
|
description: str = "Cash settlement",
|
|
payment_method: str = "cash",
|
|
currency: str = "EUR",
|
|
) -> dict[str, Any]:
|
|
"""Admin records that user paid libra (e.g. cash, bank transfer)."""
|
|
r = await client.post(
|
|
"/libra/api/v1/receivables/settle",
|
|
headers=super_user_headers,
|
|
json={
|
|
"user_id": user_id,
|
|
"amount": _amount(amount),
|
|
"description": description,
|
|
"payment_method": payment_method,
|
|
"currency": currency,
|
|
},
|
|
)
|
|
assert r.status_code == 200, f"settle_receivable failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def pay_user(
|
|
client: AsyncClient,
|
|
*,
|
|
super_user_headers: dict,
|
|
user_id: str,
|
|
amount: Amount,
|
|
description: str = "Libra pays user",
|
|
payment_method: str = "cash",
|
|
currency: str = "EUR",
|
|
) -> dict[str, Any]:
|
|
"""Admin records that libra paid user (e.g. cash, bank, lightning)."""
|
|
r = await client.post(
|
|
"/libra/api/v1/payables/pay",
|
|
headers=super_user_headers,
|
|
json={
|
|
"user_id": user_id,
|
|
"amount": _amount(amount),
|
|
"description": description,
|
|
"payment_method": payment_method,
|
|
"currency": currency,
|
|
},
|
|
)
|
|
assert r.status_code == 200, f"pay_user failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manual payment requests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def submit_manual_payment_request(
|
|
client: AsyncClient,
|
|
*,
|
|
wallet_inkey: str,
|
|
amount_sats: int,
|
|
description: str,
|
|
) -> dict[str, Any]:
|
|
"""User asks for libra to pay them via a manual (non-Lightning) route.
|
|
|
|
Body matches `CreateManualPaymentRequest`: amount in satoshis (no fiat
|
|
conversion at this endpoint), description for the admin to review.
|
|
"""
|
|
r = await client.post(
|
|
"/libra/api/v1/manual-payment-request",
|
|
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
|
|
json={"amount": amount_sats, "description": description},
|
|
)
|
|
assert r.status_code in (200, 201), (
|
|
f"submit_manual_payment_request failed: {r.status_code} {r.text}"
|
|
)
|
|
return r.json()
|
|
|
|
|
|
async def approve_manual_payment_request(
|
|
client: AsyncClient, *, super_user_headers: dict, request_id: str,
|
|
) -> dict[str, Any]:
|
|
r = await client.post(
|
|
f"/libra/api/v1/manual-payment-requests/{request_id}/approve",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, (
|
|
f"approve_manual_payment_request failed: {r.status_code} {r.text}"
|
|
)
|
|
return r.json()
|
|
|
|
|
|
async def approve_entry(
|
|
client: AsyncClient, *, super_user_headers: dict, entry_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Admin approves a pending journal entry, flipping its flag from `!` to `*`."""
|
|
r = await client.post(
|
|
f"/libra/api/v1/entries/{entry_id}/approve",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"approve_entry failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def reject_entry(
|
|
client: AsyncClient, *, super_user_headers: dict, entry_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Admin rejects a pending journal entry, marking it #voided."""
|
|
r = await client.post(
|
|
f"/libra/api/v1/entries/{entry_id}/reject",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"reject_entry failed: {r.status_code} {r.text}"
|
|
return r.json()
|
|
|
|
|
|
async def reject_manual_payment_request(
|
|
client: AsyncClient, *, super_user_headers: dict, request_id: str,
|
|
) -> dict[str, Any]:
|
|
r = await client.post(
|
|
f"/libra/api/v1/manual-payment-requests/{request_id}/reject",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, (
|
|
f"reject_manual_payment_request failed: {r.status_code} {r.text}"
|
|
)
|
|
return r.json()
|