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:
Padreug 2026-06-07 09:47:09 +02:00
commit 7a4b3022c2
14 changed files with 3710 additions and 0 deletions

View file

@ -0,0 +1,294 @@
"""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}"