From a3a9b9a8d5d972bb6f544e793d201d89fcdbf11c Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 12 May 2026 12:07:16 +0100 Subject: [PATCH 1/2] add tests --- .gitignore | 1 + tests/conftest.py | 29 +++++++ tests/test_api.py | 211 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py diff --git a/.gitignore b/.gitignore index 0152b6e..1822965 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ node_modules .mypy_cache .venv +data/ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b05a84f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +import os +from typing import Any, cast + +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from lnbits.core import migrations as core_migrations +from lnbits.core.db import db as core_db +from lnbits.core.helpers import run_migration + +from .. import lndhub_ext + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def init_ext(): + if os.path.isfile(core_db.path): + os.remove(core_db.path) + async with core_db.connect() as conn: + await run_migration(conn, core_migrations, "core") + + +@pytest_asyncio.fixture +async def client(): + app = FastAPI() + app.include_router(lndhub_ext) + transport = ASGITransport(app=cast(Any, app)) + + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b496c6b --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,211 @@ +import asyncio +from base64 import urlsafe_b64encode +from uuid import uuid4 + +import pytest +from bolt11 import decode as bolt11_decode +from httpx import AsyncClient +from lnbits.core.models.users import Account +from lnbits.core.services import create_invoice, pay_invoice, update_wallet_balance +from lnbits.core.services.users import create_user_account_no_ckeck +from lnbits.tasks import internal_invoice_queue + +from ..decorators import sanitize_token + + +async def _user_with_wallet(username: str): + account = Account(id=uuid4().hex, username=username) + user = await create_user_account_no_ckeck(account=account) + return user, user.wallets[0] + + +async def _drain_internal_invoice_queue() -> None: + while True: + try: + internal_invoice_queue.get_nowait() + except asyncio.QueueEmpty: + return + + +async def _lndhub_headers(client: AsyncClient, login: str, password: str): + auth = await client.post( + "/lndhub/ext/auth", + json={"login": login, "password": password}, + ) + assert auth.status_code == 200 + token = auth.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_sanitize_token_decodes_lndhub_bearer_token(): + token = urlsafe_b64encode(b"admin:admin-key").decode("ascii") + + assert await sanitize_token(f"Bearer {token}") == "admin-key" + + +@pytest.mark.asyncio +async def test_lndhub_addinvoice_with_expiry(client: AsyncClient): + _user, wallet = await _user_with_wallet("lhaddexpiry") + headers = await _lndhub_headers(client, "invoice", wallet.inkey) + + response = await client.post( + "/lndhub/ext/addinvoice", + json={"amt": 21, "memo": "expiry invoice", "expiry": 600}, + headers=headers, + ) + + assert response.status_code == 200 + invoice = response.json() + assert invoice["expiry"] == 600 + decoded = bolt11_decode(invoice["payment_request"]) + assert decoded.expiry == 600 + + +@pytest.mark.asyncio +async def test_lndhub_addinvoice_without_expiry(client: AsyncClient): + _user, wallet = await _user_with_wallet("lhaddnoexp") + headers = await _lndhub_headers(client, "invoice", wallet.inkey) + + response = await client.post( + "/lndhub/ext/addinvoice", + json={"amt": 21, "memo": "default expiry invoice"}, + headers=headers, + ) + + assert response.status_code == 200 + invoice = response.json() + assert invoice["expiry"] is None + decoded = bolt11_decode(invoice["payment_request"]) + assert decoded.expiry + + +@pytest.mark.asyncio +async def test_lndhub_addinvoice_rejects_invalid_expiry(client: AsyncClient): + _user, wallet = await _user_with_wallet("lhaddbadexp") + headers = await _lndhub_headers(client, "invoice", wallet.inkey) + + response = await client.post( + "/lndhub/ext/addinvoice", + json={"amt": 21, "memo": "bad expiry invoice", "expiry": "invalid"}, + headers=headers, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_lndhub_gettxs_returns_real_outgoing_payments(client: AsyncClient): + await _drain_internal_invoice_queue() + _payer_user, payer_wallet = await _user_with_wallet("lhpayer") + _receiver_user, receiver_wallet = await _user_with_wallet("lhreceiver") + headers = await _lndhub_headers(client, "admin", payer_wallet.adminkey) + + await update_wallet_balance(payer_wallet, 100_000) + await _drain_internal_invoice_queue() + + outgoing = [] + for amount in (21, 34): + invoice = await create_invoice( + wallet_id=receiver_wallet.id, + amount=amount, + memo=f"lndhub receive {amount}", + internal=True, + extra={"tag": "lndhub-test"}, + ) + payment = await pay_invoice( + wallet_id=payer_wallet.id, + payment_request=invoice.bolt11, + extra={"tag": "lndhub-test"}, + ) + outgoing.append(payment) + await _drain_internal_invoice_queue() + + response = await client.get("/lndhub/ext/gettxs?limit=100", headers=headers) + + assert response.status_code == 200 + transactions = response.json() + assert len(transactions) == 2 + assert {tx["payment_hash"] for tx in transactions} == { + payment.payment_hash for payment in outgoing + } + assert {tx["value"] for tx in transactions} == {-21, -34} + assert all(tx["type"] == "paid_invoice" for tx in transactions) + + +@pytest.mark.asyncio +async def test_lndhub_getuserinvoices_returns_real_incoming_payments( + client: AsyncClient, +): + await _drain_internal_invoice_queue() + _payer_user, payer_wallet = await _user_with_wallet("lhpayerinv") + _receiver_user, receiver_wallet = await _user_with_wallet("lhreceiverinv") + headers = await _lndhub_headers(client, "invoice", receiver_wallet.inkey) + + await update_wallet_balance(payer_wallet, 100_000) + await _drain_internal_invoice_queue() + + invoices = [] + for amount in (21, 34): + invoice = await create_invoice( + wallet_id=receiver_wallet.id, + amount=amount, + memo=f"lndhub receive {amount}", + internal=True, + extra={"tag": "lndhub-test"}, + ) + await pay_invoice(wallet_id=payer_wallet.id, payment_request=invoice.bolt11) + invoices.append(invoice) + await _drain_internal_invoice_queue() + + response = await client.get( + "/lndhub/ext/getuserinvoices?limit=100", headers=headers + ) + + assert response.status_code == 200 + user_invoices = response.json() + assert len(user_invoices) == 2 + assert {invoice["payment_hash"] for invoice in user_invoices} == { + invoice.payment_hash for invoice in invoices + } + assert {invoice["amt"] for invoice in user_invoices} == {21, 34} + assert all(invoice["ispaid"] is True for invoice in user_invoices) + + +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="/gettxs currently filters outgoing payments only, so incoming payments " + "are only visible via /getuserinvoices." +) +async def test_lndhub_gettxs_includes_real_incoming_payments_for_clients( + client: AsyncClient, +): + await _drain_internal_invoice_queue() + _payer_user, payer_wallet = await _user_with_wallet("lhpayerreg") + _receiver_user, receiver_wallet = await _user_with_wallet("lhreceiverreg") + headers = await _lndhub_headers(client, "invoice", receiver_wallet.inkey) + + await update_wallet_balance(payer_wallet, 100_000) + await _drain_internal_invoice_queue() + + invoices = [] + for amount in (21, 34): + invoice = await create_invoice( + wallet_id=receiver_wallet.id, + amount=amount, + memo=f"lndhub receive {amount}", + internal=True, + extra={"tag": "lndhub-test"}, + ) + await pay_invoice(wallet_id=payer_wallet.id, payment_request=invoice.bolt11) + invoices.append(invoice) + await _drain_internal_invoice_queue() + + response = await client.get("/lndhub/ext/gettxs?limit=100", headers=headers) + + assert response.status_code == 200 + transactions = response.json() + assert len(transactions) == 2 + assert {tx["payment_hash"] for tx in transactions} == { + invoice.payment_hash for invoice in invoices + } From efe8379c16061addc78afb33804e916b8a0d634b Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 12 May 2026 12:07:33 +0100 Subject: [PATCH 2/2] feat: add optional expiry --- models.py | 1 + views_api.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/models.py b/models.py index fb8b374..bb6eb74 100644 --- a/models.py +++ b/models.py @@ -10,6 +10,7 @@ class LndhubAddInvoice(BaseModel): amt: int = Query(...) memo: str = Query(...) preimage: str = Query(None) + expiry: int = Query(None) class LndhubAuthData(BaseModel): diff --git a/views_api.py b/views_api.py index cf7057a..e25d714 100644 --- a/views_api.py +++ b/views_api.py @@ -42,6 +42,7 @@ async def lndhub_addinvoice( wallet_id=wallet.wallet.id, amount=data.amt, memo=data.memo or settings.lnbits_site_title, + expiry=data.expiry, extra={"tag": "lndhub"}, ) return { @@ -50,6 +51,7 @@ async def lndhub_addinvoice( "add_index": "500", "r_hash": to_buffer(payment.payment_hash), "hash": payment.payment_hash, + "expiry": data.expiry, }