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>
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Balance assertion CRUD + reconciliation summary endpoints.
|
|
|
|
Endpoints:
|
|
- `POST /libra/api/v1/assertions` — create + check
|
|
- `GET /libra/api/v1/assertions` — list with filters
|
|
- `GET /libra/api/v1/assertions/{id}` — fetch one
|
|
- `POST /libra/api/v1/assertions/{id}/check` — re-check
|
|
- `DELETE /libra/api/v1/assertions/{id}` — remove
|
|
|
|
All `require_super_user` (libra-level, wallet admin-key).
|
|
|
|
The create endpoint is hybrid: it posts a Beancount `balance` directive via
|
|
Fava (source of truth), persists the assertion metadata in libra's DB, and
|
|
re-checks immediately. On mismatch it returns 409 with the diff payload.
|
|
"""
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
|
|
# Tests that try to actually create + check an assertion all hit issue #39:
|
|
# `format_balance` returns a Beancount source string but `fava.add_entry`
|
|
# expects a dict, so Fava 500s on every assertion-create call. The contract
|
|
# violation is on libra's side; mark these strict-xfail so they go green
|
|
# automatically once #39 lands and the format_balance return shape is fixed.
|
|
ASSERTION_CREATE_BROKEN = pytest.mark.xfail(
|
|
reason="libra/issues/39 — POST /assertions submits a Beancount source string "
|
|
"to Fava's JSON API and 500s. Drop this marker when the format_balance "
|
|
"return type is changed to a dict.",
|
|
strict=True,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers (local — assertion endpoints don't have wrapper helpers yet)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _create_assertion(
|
|
client, *, super_user_headers, account_id, expected_sats,
|
|
tolerance_sats=0, fiat_currency=None, expected_fiat=None,
|
|
):
|
|
body = {
|
|
"account_id": account_id,
|
|
"expected_balance_sats": expected_sats,
|
|
"tolerance_sats": tolerance_sats,
|
|
}
|
|
if fiat_currency:
|
|
body["fiat_currency"] = fiat_currency
|
|
body["expected_balance_fiat"] = str(expected_fiat) if expected_fiat is not None else "0"
|
|
return await client.post(
|
|
"/libra/api/v1/assertions", headers=super_user_headers, json=body,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_assertion_against_empty_account_passes(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
"""An asset account with no postings has a 0 balance — asserting 0
|
|
should pass and the resulting assertion has status='passed'."""
|
|
r = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=standard_accounts["assets_cash"]["id"],
|
|
expected_sats=0,
|
|
)
|
|
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
|
|
body = r.json()
|
|
assert body.get("status") == "passed", (
|
|
f"expected status='passed' for 0=0, got {body.get('status')} body={body}"
|
|
)
|
|
assert body.get("difference_sats", 0) == 0
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_assertion_with_wrong_balance_returns_409(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
"""When the actual balance doesn't match expected, the create endpoint
|
|
returns 409 Conflict with the diff payload — Beancount validates it
|
|
server-side after the directive lands."""
|
|
r = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=standard_accounts["assets_cash"]["id"],
|
|
expected_sats=999_999, # wildly wrong for empty account
|
|
)
|
|
assert r.status_code == 409, f"expected 409, got {r.status_code}: {r.text}"
|
|
# 409 body should expose the diff so a UI can render the gap.
|
|
detail = r.json().get("detail")
|
|
assert isinstance(detail, dict), f"expected structured detail, got {detail!r}"
|
|
assert detail.get("expected_sats") == 999_999
|
|
assert detail.get("actual_sats") == 0
|
|
assert detail.get("difference_sats") == 999_999 or detail.get("difference_sats") == -999_999
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_assertion_with_tolerance_accepts_small_diff(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
"""A tolerance of N sats lets actual-vs-expected diverge by ≤N."""
|
|
r = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=standard_accounts["assets_cash"]["id"],
|
|
expected_sats=50,
|
|
tolerance_sats=100, # actual=0, expected=50, diff=50, tolerance=100 → passes
|
|
)
|
|
assert r.status_code == 200, f"expected 200 within tolerance, got {r.status_code}: {r.text}"
|
|
assert r.json().get("status") == "passed"
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_list_assertions_returns_created(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
"""Newly created assertions show up in the list filtered by account."""
|
|
account_id = standard_accounts["assets_cash"]["id"]
|
|
|
|
create = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=account_id,
|
|
expected_sats=0,
|
|
)
|
|
assert create.status_code == 200
|
|
assertion_id = create.json()["id"]
|
|
|
|
r = await client.get(
|
|
f"/libra/api/v1/assertions?account_id={account_id}",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"list assertions: {r.status_code} {r.text}"
|
|
ids = [a.get("id") for a in r.json()]
|
|
assert assertion_id in ids, f"created assertion {assertion_id} missing from list {ids}"
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_get_assertion_by_id(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
create = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=standard_accounts["assets_cash"]["id"],
|
|
expected_sats=0,
|
|
)
|
|
assert create.status_code == 200
|
|
assertion_id = create.json()["id"]
|
|
|
|
r = await client.get(
|
|
f"/libra/api/v1/assertions/{assertion_id}",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"get assertion: {r.status_code} {r.text}"
|
|
assert r.json().get("id") == assertion_id
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_recheck_assertion_via_check_endpoint(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
"""`POST /assertions/{id}/check` re-evaluates and returns the updated
|
|
assertion record. Idempotent against a stable ledger state."""
|
|
create = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=standard_accounts["assets_cash"]["id"],
|
|
expected_sats=0,
|
|
)
|
|
assertion_id = create.json()["id"]
|
|
|
|
r = await client.post(
|
|
f"/libra/api/v1/assertions/{assertion_id}/check",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"recheck: {r.status_code} {r.text}"
|
|
assert r.json().get("status") == "passed"
|
|
|
|
|
|
@ASSERTION_CREATE_BROKEN
|
|
@pytest.mark.anyio
|
|
async def test_delete_assertion_removes_it(
|
|
client, super_user_headers, standard_accounts,
|
|
):
|
|
create = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=standard_accounts["assets_cash"]["id"],
|
|
expected_sats=0,
|
|
)
|
|
assertion_id = create.json()["id"]
|
|
|
|
r = await client.delete(
|
|
f"/libra/api/v1/assertions/{assertion_id}",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code in (200, 204), f"delete: {r.status_code} {r.text}"
|
|
|
|
# Subsequent GET should 404.
|
|
r = await client.get(
|
|
f"/libra/api/v1/assertions/{assertion_id}",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 404, f"expected 404 after delete, got {r.status_code}"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_assertion_unknown_account_returns_404(
|
|
client, super_user_headers,
|
|
):
|
|
"""Account-not-found check happens before any Beancount write."""
|
|
r = await _create_assertion(
|
|
client,
|
|
super_user_headers=super_user_headers,
|
|
account_id=f"nonexistent-{uuid4().hex[:6]}",
|
|
expected_sats=0,
|
|
)
|
|
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_non_super_user_cannot_create_assertion(
|
|
client, configured_user, standard_accounts,
|
|
):
|
|
"""Wallet admin-key of a regular user fails the super-user identity
|
|
check — 403."""
|
|
_, wallet = configured_user
|
|
r = await client.post(
|
|
"/libra/api/v1/assertions",
|
|
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
|
|
json={
|
|
"account_id": standard_accounts["assets_cash"]["id"],
|
|
"expected_balance_sats": 0,
|
|
},
|
|
)
|
|
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
|
|
assert "super" in r.text.lower()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_list_assertions_invalid_status_returns_400(
|
|
client, super_user_headers,
|
|
):
|
|
"""Status filter is validated against the AssertionStatus enum."""
|
|
r = await client.get(
|
|
"/libra/api/v1/assertions?status=not_a_status",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
|
assert "status" in r.text.lower()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_reconciliation_summary_endpoint(client, super_user_headers):
|
|
"""`GET /reconciliation/summary` responds 200 and returns a structured
|
|
payload even when no assertions exist. Smoke-shape only — exact counts
|
|
depend on ledger history.
|
|
|
|
Doesn't pre-create an assertion (#39 blocks that path); the summary
|
|
endpoint should still serve a default empty shape.
|
|
"""
|
|
r = await client.get(
|
|
"/libra/api/v1/reconciliation/summary",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"reconciliation summary: {r.status_code} {r.text}"
|
|
payload = r.json()
|
|
assert isinstance(payload, dict), f"expected dict, got {type(payload)}"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_daily_reconciliation_task_runs(
|
|
client, super_user_headers,
|
|
):
|
|
"""The daily-reconciliation task endpoint returns 200 even when no
|
|
assertions exist — it's the entry point that ops cron hits."""
|
|
r = await client.post(
|
|
"/libra/api/v1/tasks/daily-reconciliation",
|
|
headers=super_user_headers,
|
|
)
|
|
assert r.status_code == 200, f"daily-reconciliation: {r.status_code} {r.text}"
|