libra/tests/helpers.py
Padreug 89f0f8ac3a test(accounts): cover admin add-account endpoint
10 integration tests for POST /api/v1/admin/accounts: unconstrained Open
write + escaped description metadata, explicit-currency path, duplicate->409,
invalid-prefix->400, invalid-characters->400 (parametrized), super-user-only
->403. Adds the add_chart_account helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00

428 lines
13 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, Response
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()
async def add_chart_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
description: Optional[str] = None,
) -> Response:
"""Super user adds a chart-of-accounts entry via the admin endpoint
(POST /api/v1/admin/accounts). Returns the raw Response so callers can
assert on status codes (201 / 400 / 409 / 403)."""
body: dict[str, Any] = {"name": name}
if description is not None:
body["description"] = description
return await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json=body,
)
# ---------------------------------------------------------------------------
# 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,
reference: Optional[str] = None,
) -> 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,
"reference": reference,
},
)
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",
reference: Optional[str] = None,
) -> 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,
"reference": reference,
},
)
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) -> dict[str, Any]:
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()
async def list_pending_entries(
client: AsyncClient, *, super_user_headers: dict,
) -> list[dict]:
"""Admin lists pending (`!`) entries awaiting approval."""
r = await client.get(
"/libra/api/v1/entries/pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"list_pending_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()