Files
lnbits/tests/unit/test_services_payments.py
T
2026-03-31 09:48:43 +03:00

457 lines
15 KiB
Python

from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import (
create_account,
create_payment,
create_wallet,
get_payment,
get_payments,
update_payment,
)
from lnbits.core.models import (
Account,
CreateInvoice,
CreatePayment,
PaymentState,
Wallet,
)
from lnbits.core.services.payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_payment_status,
check_pending_payments,
check_time_limit_between_transactions,
check_transaction_status,
check_wallet_limits,
create_payment_request,
get_payments_daily_stats,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
)
from lnbits.db import Filters
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings
from lnbits.wallets.base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentSuccessStatus,
)
@pytest.mark.anyio
async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixture):
wallet_payment = SimpleNamespace(checking_id="wallet")
fiat_payment = SimpleNamespace(checking_id="fiat")
wallet_mock = mocker.patch(
"lnbits.core.services.payments.create_wallet_invoice",
mocker.AsyncMock(return_value=wallet_payment),
)
fiat_mock = mocker.patch(
"lnbits.core.services.payments.create_fiat_invoice",
mocker.AsyncMock(return_value=fiat_payment),
)
assert (
await create_payment_request("wallet-1", CreateInvoice(amount=1))
== wallet_payment
)
assert (
await create_payment_request(
"wallet-1",
CreateInvoice(amount=1, fiat_provider="stripe"),
)
== fiat_payment
)
wallet_mock.assert_awaited_once()
fiat_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFixture):
wallet = await _create_wallet()
failed_id = await _create_payment(wallet)
success_id = await _create_payment(wallet)
failed_payment = await get_payment(failed_id)
success_payment = await get_payment(success_id)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(side_effect=[PaymentFailedStatus(), PaymentSuccessStatus()]),
)
await update_pending_payment(failed_payment)
await update_pending_payment(success_payment)
assert (await get_payment(failed_id)).status == PaymentState.FAILED
assert (await get_payment(success_id)).status == PaymentState.SUCCESS
bulk_wallet = await _create_wallet()
bulk_failed_id = await _create_payment(bulk_wallet)
bulk_success_id = await _create_payment(bulk_wallet)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(side_effect=[PaymentFailedStatus(), PaymentSuccessStatus()]),
)
await update_pending_payments(bulk_wallet.id)
bulk_statuses = {
(await get_payment(bulk_failed_id)).status,
(await get_payment(bulk_success_id)).status,
}
assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS}
@pytest.mark.anyio
async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items(
mocker: MockerFixture,
):
class VoidWallet:
pass
class FakeWalletSource:
pass
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=VoidWallet(),
)
sleep_mock = mocker.patch(
"lnbits.core.services.payments.asyncio.sleep", mocker.AsyncMock()
)
await check_pending_payments()
sleep_mock.assert_not_awaited()
existing_pending = await get_payments(pending=True, exclude_uncheckable=True)
wallet = await _create_wallet()
checking_id = await _create_payment(wallet)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=FakeWalletSource(),
)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(return_value=PaymentSuccessStatus()),
)
try:
await check_pending_payments()
finally:
for payment in existing_pending:
payment.status = PaymentState.PENDING
await update_payment(payment)
assert (await get_payment(checking_id)).status == PaymentState.SUCCESS
assert sleep_mock.await_count >= 1
@pytest.mark.anyio
async def test_update_wallet_balance_validates_credit_and_debit(
settings: Settings, mocker: MockerFixture
):
wallet = await _create_wallet()
wallet.balance_msat = 20_000
with pytest.raises(ValueError, match="Amount cannot be 0."):
await update_wallet_balance(wallet, 0)
with pytest.raises(ValueError, match="can not go into negative balance"):
await update_wallet_balance(wallet, -30)
payment_secret = (uuid4().hex * 2)[:64]
payment_hash = (uuid4().hex * 2)[:64]
mocker.patch(
"lnbits.core.services.payments.random_secret_and_hash",
return_value=(payment_secret, payment_hash),
)
mocker.patch(
"lnbits.core.services.payments.fake_privkey",
return_value="privkey",
)
mocker.patch(
"lnbits.core.services.payments.bolt11_encode",
return_value="encoded-bolt11",
)
await update_wallet_balance(wallet, -10)
debit_payment = await get_payment("internal_" + payment_hash)
assert debit_payment is not None
assert debit_payment.amount == -10_000
assert debit_payment.status == PaymentState.SUCCESS
original_max_balance = settings.lnbits_wallet_limit_max_balance
try:
settings.lnbits_wallet_limit_max_balance = 21
with pytest.raises(ValueError, match="amount exceeds maximum balance"):
await update_wallet_balance(wallet, 5)
settings.lnbits_wallet_limit_max_balance = 0
queue_mock = mocker.patch(
"lnbits.tasks.internal_invoice_queue_put",
mocker.AsyncMock(),
)
await update_wallet_balance(wallet, 5)
finally:
settings.lnbits_wallet_limit_max_balance = original_max_balance
credit_payments = [
payment
for payment in await get_payments(wallet_id=wallet.id, incoming=True)
if payment.memo == "Admin credit"
]
assert credit_payments
assert credit_payments[0].status == PaymentState.SUCCESS
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id)
@pytest.mark.anyio
async def test_check_wallet_limits_and_time_limit(
settings: Settings, mocker: MockerFixture
):
time_limit_mock = mocker.patch(
"lnbits.core.services.payments.check_time_limit_between_transactions",
mocker.AsyncMock(),
)
daily_limit_mock = mocker.patch(
"lnbits.core.services.payments.check_wallet_daily_withdraw_limit",
mocker.AsyncMock(),
)
await check_wallet_limits("wallet-1", 1_000)
time_limit_mock.assert_awaited_once_with("wallet-1", None)
daily_limit_mock.assert_awaited_once_with("wallet-1", 1_000, None)
wallet = await _create_wallet()
await _create_payment(wallet, amount_msat=-2_000)
original_limit = settings.lnbits_wallet_limit_secs_between_trans
try:
settings.lnbits_wallet_limit_secs_between_trans = 30
with pytest.raises(PaymentError) as exc_info:
await check_time_limit_between_transactions(wallet.id)
assert "30 seconds between payments" in exc_info.value.message
other_wallet = await _create_wallet()
assert await check_time_limit_between_transactions(other_wallet.id) is None
finally:
settings.lnbits_wallet_limit_secs_between_trans = original_limit
@pytest.mark.anyio
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
mocker: MockerFixture,
):
wallet = await _create_wallet()
wallet.currency = "EUR"
mocker.patch(
"lnbits.core.services.payments.fiat_amount_as_satoshis",
mocker.AsyncMock(return_value=200),
)
sat_to_fiat_mock = mocker.patch(
"lnbits.core.services.payments.satoshis_amount_as_fiat",
mocker.AsyncMock(return_value=1.5),
)
amount_sat, fiat_amounts = await calculate_fiat_amounts(2.0, wallet, "USD")
assert amount_sat == 200
assert fiat_amounts["fiat_currency"] == "USD"
assert fiat_amounts["wallet_fiat_currency"] == "EUR"
assert fiat_amounts["wallet_fiat_amount"] == 1.5
sat_to_fiat_mock.side_effect = Exception("boom")
amount_sat, fiat_amounts = await calculate_fiat_amounts(10, wallet, "sat", extra={})
assert amount_sat == 10
assert fiat_amounts == {}
@pytest.mark.anyio
async def test_check_transaction_status_and_payment_status(mocker: MockerFixture):
wallet = await _create_wallet()
missing_hash = uuid4().hex
assert (await check_transaction_status(wallet.id, missing_hash)).pending is True
success_hash = uuid4().hex
success_id = await _create_payment(
wallet,
status=PaymentState.SUCCESS,
payment_hash=success_hash,
fee=-123,
)
success_status = await check_transaction_status(wallet.id, success_hash)
assert success_status.success is True
assert success_status.fee_msat == -123
pending_hash = uuid4().hex
await _create_payment(wallet, payment_hash=pending_hash)
mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(return_value=PaymentFailedStatus()),
)
assert (await check_transaction_status(wallet.id, pending_hash)).failed is True
internal_success = await get_payment(success_id)
internal_success.checking_id = "internal_" + internal_success.payment_hash
internal_success.status = PaymentState.SUCCESS.value
assert (await check_payment_status(internal_success)).success is True
internal_failed = await get_payment(success_id)
internal_failed.checking_id = "internal_" + internal_failed.payment_hash
internal_failed.status = PaymentState.FAILED.value
assert (await check_payment_status(internal_failed)).failed is True
internal_fiat = await get_payment(success_id)
internal_fiat.checking_id = "fiat_" + internal_fiat.payment_hash
internal_fiat.status = PaymentState.PENDING.value
internal_fiat.fiat_provider = "stripe"
mocker.patch(
"lnbits.core.services.payments.check_fiat_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
assert (await check_payment_status(internal_fiat)).success is True
outgoing = await get_payment(success_id)
outgoing.checking_id = "external-out"
outgoing.amount = -2_000
incoming = await get_payment(success_id)
incoming.checking_id = "external-in"
incoming.amount = 2_000
funding_source = SimpleNamespace(
get_payment_status=mocker.AsyncMock(return_value=PaymentSuccessStatus()),
get_invoice_status=mocker.AsyncMock(return_value=PaymentPendingStatus()),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=funding_source,
)
assert (await check_payment_status(outgoing)).success is True
assert (await check_payment_status(incoming)).pending is True
@pytest.mark.anyio
async def test_get_payments_daily_stats_fills_missing_dates():
wallet = await _create_wallet()
user_id = wallet.user
now = datetime.now(timezone.utc).replace(hour=12, minute=0, second=0, microsecond=0)
await _create_payment(
wallet,
amount_msat=2_000,
status=PaymentState.SUCCESS,
time=now - timedelta(days=2),
)
await _create_payment(
wallet,
amount_msat=-500,
status=PaymentState.SUCCESS,
fee=100,
time=now,
)
stats = await get_payments_daily_stats(Filters(), user_id=user_id)
assert [point.date.date() for point in stats[-3:]] == [
(now - timedelta(days=2)).date(),
(now - timedelta(days=1)).date(),
now.date(),
]
assert [point.balance for point in stats[-3:]] == [2, 2, 1]
assert stats[-1].fee == 0
@pytest.mark.anyio
async def test_settle_and_cancel_hold_invoice_persist_status(mocker: MockerFixture):
wallet = await _create_wallet()
checking_id = await _create_payment(wallet, payment_hash="33" * 32)
payment = await get_payment(checking_id)
funding_source = SimpleNamespace(
settle_hold_invoice=mocker.AsyncMock(
return_value=InvoiceResponse(ok=True, checking_id="settled")
),
cancel_hold_invoice=mocker.AsyncMock(
return_value=InvoiceResponse(ok=True, checking_id="cancelled")
),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=funding_source,
)
mocker.patch(
"lnbits.core.services.payments.verify_preimage",
return_value=False,
)
with pytest.raises(InvoiceError, match="Invalid preimage."):
await settle_hold_invoice(payment, "00" * 32)
mocker.patch(
"lnbits.core.services.payments.verify_preimage",
return_value=True,
)
assert (await settle_hold_invoice(payment, "11" * 32)).ok is True
assert (await cancel_hold_invoice(payment)).ok is True
stored = await get_payment(checking_id)
assert stored.preimage == "11" * 32
assert stored.extra["hold_invoice_settled"] is True
assert stored.extra["hold_invoice_cancelled"] is True
assert stored.status == PaymentState.FAILED
def _account() -> Account:
account_id = uuid4().hex
return Account(id=account_id, username=f"user_{account_id[:8]}")
async def _create_wallet() -> Wallet:
account = _account()
await create_account(account)
return await create_wallet(
user_id=account.id, wallet_name=f"wallet_{account.id[:8]}"
)
async def _create_payment(
wallet: Wallet,
*,
amount_msat: int = 2_000,
status: PaymentState = PaymentState.PENDING,
checking_id: str | None = None,
payment_hash: str | None = None,
fee: int = 0,
time: datetime | None = None,
) -> str:
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
payment_hash = payment_hash or uuid4().hex
payment = await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet.id,
payment_hash=payment_hash,
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo="memo",
fee=fee,
),
status=status,
)
if time:
payment.time = time
payment.created_at = time
payment.updated_at = time
await update_payment(payment)
return checking_id