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
211
tests/test_entries_user_api.py
Normal file
211
tests/test_entries_user_api.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""User-side expense submission flow — `POST /libra/api/v1/entries/expense`.
|
||||
|
||||
Covers:
|
||||
- Submission lands as a pending entry, visible to the user, doesn't move
|
||||
the cleared-only balance.
|
||||
- Cross-user isolation — user B can't see user A's entries.
|
||||
- Permission gating, currency validation, missing user-wallet setup.
|
||||
- Multiple submissions accumulate in the user journal listing.
|
||||
|
||||
Settlement, approval, and balance-after-approval are exercised in
|
||||
`test_smoke.py` (one canonical path) and `test_balances_api.py` (the mixed
|
||||
income+expense display scenario the user named).
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import (
|
||||
create_account,
|
||||
get_balance,
|
||||
list_user_entries,
|
||||
post_expense,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expense_creates_pending_entry_visible_in_user_journal(
|
||||
client, configured_user, standard_accounts,
|
||||
):
|
||||
"""Submitting an expense creates a pending (`!`) entry the user can see
|
||||
immediately. The cleared-only balance query is unchanged because pending
|
||||
entries are excluded."""
|
||||
_, wallet = configured_user
|
||||
|
||||
response = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="25.00",
|
||||
currency="EUR",
|
||||
description="Test groceries",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
assert response.get("id"), f"expected id in response, got {response}"
|
||||
|
||||
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||
entries = listing.get("entries", [])
|
||||
assert any(
|
||||
"Test groceries" in (e.get("description") or "") for e in entries
|
||||
), f"submitted expense missing from /entries/user: {entries}"
|
||||
|
||||
bal = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||
assert not bal.get("fiat_balances"), (
|
||||
f"pending entry should not affect cleared balance, got {bal}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_cannot_see_other_users_entries(
|
||||
client, configured_user, configured_user_b, standard_accounts,
|
||||
):
|
||||
"""User A submits an expense; user B's `/entries/user` listing is
|
||||
scoped to B and never references A's user-id account fragment."""
|
||||
user_a, wallet_a = configured_user
|
||||
_, wallet_b = configured_user_b
|
||||
|
||||
await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet_a.inkey,
|
||||
user_wallet_id=wallet_a.id,
|
||||
amount="40.00",
|
||||
currency="EUR",
|
||||
description=f"A-private-{uuid4().hex[:6]}",
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
|
||||
listing_b = await list_user_entries(client, wallet_inkey=wallet_b.inkey)
|
||||
a_short = user_a.id[:8]
|
||||
for entry in listing_b.get("entries", []):
|
||||
for posting in entry.get("postings", []):
|
||||
assert a_short not in posting.get("account", ""), (
|
||||
f"user B's listing leaked user A's account: {posting}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expense_without_permission_returns_403(
|
||||
client, super_user_headers, configured_user,
|
||||
):
|
||||
"""Submitting to an expense account the user has no `submit_expense`
|
||||
permission on returns 403 with a permission-error detail."""
|
||||
_, wallet = configured_user
|
||||
|
||||
# Fresh expense account that no permission was granted on.
|
||||
new_account = await create_account(
|
||||
client,
|
||||
super_user_headers=super_user_headers,
|
||||
name=f"Expenses:Test:Unguarded-{uuid4().hex[:6]}",
|
||||
account_type="expense",
|
||||
)
|
||||
|
||||
r = await client.post(
|
||||
"/libra/api/v1/entries/expense",
|
||||
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
|
||||
json={
|
||||
"description": "Should be denied",
|
||||
"amount": "10.00",
|
||||
"currency": "EUR",
|
||||
"expense_account": new_account["name"],
|
||||
"user_wallet": wallet.id,
|
||||
"is_equity": False,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
|
||||
assert "permission" in r.text.lower(), (
|
||||
f"expected permission error message, got {r.text!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expense_with_unknown_currency_returns_400(
|
||||
client, configured_user, standard_accounts,
|
||||
):
|
||||
"""An unsupported currency is rejected with 400 before any Fava call."""
|
||||
_, wallet = configured_user
|
||||
|
||||
r = await client.post(
|
||||
"/libra/api/v1/entries/expense",
|
||||
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
|
||||
json={
|
||||
"description": "Unknown currency",
|
||||
"amount": "10.00",
|
||||
"currency": "XYZ",
|
||||
"expense_account": standard_accounts["expense_food"]["name"],
|
||||
"user_wallet": wallet.id,
|
||||
"is_equity": False,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
||||
assert "currency" in r.text.lower(), (
|
||||
f"expected currency error message, got {r.text!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expense_without_user_wallet_configured_returns_400(
|
||||
client, libra_user, libra_wallet, standard_accounts, # noqa: ARG001 (libra_wallet ensures session-level setup)
|
||||
):
|
||||
"""A user whose own libra wallet isn't configured can't submit expenses.
|
||||
|
||||
`libra_user` (vs `configured_user`) skips the `PUT /user/wallet` step
|
||||
on purpose so the precondition fires.
|
||||
"""
|
||||
_, wallet = libra_user
|
||||
|
||||
r = await client.post(
|
||||
"/libra/api/v1/entries/expense",
|
||||
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
|
||||
json={
|
||||
"description": "Missing user wallet setup",
|
||||
"amount": "10.00",
|
||||
"currency": "EUR",
|
||||
"expense_account": standard_accounts["expense_food"]["name"],
|
||||
"user_wallet": wallet.id,
|
||||
"is_equity": False,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
||||
assert "wallet" in r.text.lower(), (
|
||||
f"expected wallet-config error, got {r.text!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_expenses_accumulate_in_user_journal(
|
||||
client, configured_user, standard_accounts,
|
||||
):
|
||||
"""Each submission shows up in `/entries/user`; the listing's `total`
|
||||
grows by exactly the number of submissions."""
|
||||
_, wallet = configured_user
|
||||
|
||||
initial = await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||
initial_total = initial.get("total", 0)
|
||||
|
||||
tag = uuid4().hex[:6]
|
||||
descriptions = [f"Coffee-{tag}", f"Bread-{tag}", f"Vegetables-{tag}"]
|
||||
for description in descriptions:
|
||||
await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="7.50",
|
||||
currency="EUR",
|
||||
description=description,
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
)
|
||||
|
||||
final = await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||
final_total = final.get("total", 0)
|
||||
assert final_total - initial_total == len(descriptions), (
|
||||
f"expected total to grow by {len(descriptions)}, "
|
||||
f"went from {initial_total} to {final_total}"
|
||||
)
|
||||
|
||||
# Libra appends " (<amount> <currency>)" to entry descriptions, so check
|
||||
# substring rather than exact match.
|
||||
final_descs = [e.get("description") or "" for e in final.get("entries", [])]
|
||||
for description in descriptions:
|
||||
assert any(description in d for d in final_descs), (
|
||||
f"missing {description} from journal listing: {final_descs}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue