lnbits/tests/wallets/test_bark.py
Arc d1a431afc8
fix: harden ambiguous payments (#4110)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-31 14:29:23 +01:00

301 lines
9 KiB
Python

import asyncio
import json
import httpx
import pytest
from bolt11 import decode as bolt11_decode
from lnbits.wallets.bark import BarkWallet
from lnbits.wallets.base import PaymentResponse
BOLT11 = (
"lnbc1u1pjl0uhypp5yxvdqq923atm9ywkpgtu3yxv9w2n44ensrkwfyagvmzqhml2x9gq"
"dpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsssp5h3qlnnlfq"
"ekquacwwj9yu7fhujyzxhzqegpxenscw45pgv6xakfq9qyyssqqjruygw0jrcg3365jksxn"
"6yhsxx7c5pdjrjdlyvuhs7xh8r409h4e3kucc54kgh34pscaq3mg7hn55l8a0qszgzex80"
"amwrp4gkdgqcpkse88y"
)
class FakeWebSocket:
def __init__(self, messages: list[dict]):
self.messages = messages
async def recv(self):
return json.dumps(self.messages.pop(0))
class FakeConnection:
def __init__(self, websocket: FakeWebSocket):
self.websocket = websocket
async def __aenter__(self):
return self.websocket
async def __aexit__(self, *_):
return None
@pytest.fixture
def bark_wallet(settings):
settings.bark_api_endpoint = "http://localhost:3000"
settings.bark_api_token = "test-token"
return BarkWallet()
def payment_response(status_code: int, **kwargs) -> httpx.Response:
request = httpx.Request("POST", "http://localhost:3000/api/v1/lightning/pay")
return httpx.Response(status_code, request=request, **kwargs)
@pytest.mark.anyio
async def test_paid_invoices_stream_yields_successful_receive(
bark_wallet: BarkWallet, mocker
):
checking_id = bolt11_decode(BOLT11).payment_hash
notification = {
"type": "movement-updated",
"movement": {
"status": "successful",
"received_on": [
{
"destination": {"type": "invoice", "value": BOLT11},
"amount_sat": 100,
}
],
},
}
websocket = FakeWebSocket([notification])
connect = mocker.patch(
"lnbits.wallets.bark.connect", return_value=FakeConnection(websocket)
)
request = mocker.patch.object(
bark_wallet,
"_request_json",
side_effect=[
"websocket-ticket",
{"state": "preimage-revealed", "payment_preimage": "preimage"},
],
)
stream = bark_wallet.paid_invoices_stream()
try:
assert await anext(stream) == checking_id
status = await bark_wallet.get_invoice_status(checking_id)
assert status.success
assert status.preimage == "preimage"
finally:
await stream.aclose()
await bark_wallet.cleanup()
assert request.await_args_list == [
mocker.call("GET", "/api/v1/notifications/ws/ticket", timeout=10),
mocker.call("GET", f"/api/v1/lightning/receives/{checking_id}"),
]
connect.assert_called_once_with(
"ws://localhost:3000/api/v1/notifications/ws?ticket=websocket-ticket"
)
def test_incoming_payment_hash_ignores_non_receive_movements(bark_wallet: BarkWallet):
notification = {
"type": "movement-updated",
"movement": {
"status": "successful",
"received_on": [],
"sent_to": [{"destination": {"type": "invoice", "value": BOLT11}}],
},
}
assert bark_wallet._incoming_payment_hash(notification) is None
@pytest.mark.anyio
@pytest.mark.parametrize(
("data", "expected_paid"),
[
(
{
"state": "settled",
"settled_at": "2026-07-16T12:00:00Z",
"payment_preimage": "preimage",
},
True,
),
(
{
"finished_at": "2026-07-16T12:00:00Z",
"preimage_revealed_at": "2026-07-16T12:00:00Z",
"payment_preimage": "preimage",
},
True,
),
({"state": "awaiting-payment"}, None),
({"finished_at": "2026-07-16T12:00:00Z"}, False),
],
ids=["settled", "legacy-settled", "pending", "failed"],
)
async def test_get_invoice_status_maps_receive_state(
bark_wallet: BarkWallet, mocker, data: dict, expected_paid: bool | None
):
checking_id = bolt11_decode(BOLT11).payment_hash
request = mocker.patch.object(bark_wallet, "_request_json", return_value=data)
status = await bark_wallet.get_invoice_status(checking_id)
assert status.paid is expected_paid
assert status.preimage == ("preimage" if expected_paid else None)
request.assert_awaited_once_with("GET", f"/api/v1/lightning/receives/{checking_id}")
@pytest.mark.anyio
@pytest.mark.parametrize("ok", [True, None], ids=["settled", "pending"])
async def test_send_payment_checks_status_after_payment_is_initiated(
bark_wallet: BarkWallet, mocker, settings, ok: bool | None
):
settings.lnbits_funding_source_pay_invoice_wait_seconds = 0
checking_id = bolt11_decode(BOLT11).payment_hash
expected = PaymentResponse(ok=ok, checking_id=checking_id)
mocker.patch.object(
bark_wallet.client,
"post",
return_value=payment_response(
200, json={"message": "Payment initiated successfully"}
),
)
get_status = mocker.patch.object(
bark_wallet, "_payment_response_from_status", return_value=expected
)
response = await bark_wallet._send_payment(BOLT11, checking_id)
assert response == expected
assert bark_wallet.pending_payments[checking_id] == BOLT11
get_status.assert_awaited_once_with(checking_id)
@pytest.mark.anyio
async def test_send_payment_waits_for_successful_movement_notification(
bark_wallet: BarkWallet, mocker, settings
):
settings.lnbits_funding_source_pay_invoice_wait_seconds = 5
checking_id = bolt11_decode(BOLT11).payment_hash
mocker.patch.object(
bark_wallet.client,
"post",
return_value=payment_response(
200, json={"message": "Payment initiated successfully"}
),
)
mocker.patch.object(
bark_wallet,
"_payment_response_from_status",
return_value=PaymentResponse(ok=None, checking_id=checking_id),
)
payment_task = asyncio.create_task(bark_wallet._send_payment(BOLT11, checking_id))
await asyncio.sleep(0)
bark_wallet._notify_outgoing_payment(
{
"type": "movement-updated",
"movement": {
"status": "successful",
"offchain_fee_sat": 2,
"metadata": {"payment_preimage": "preimage"},
"sent_to": [
{
"destination": {"type": "invoice", "value": BOLT11},
"amount_sat": 100,
}
],
},
}
)
response = await payment_task
assert response.ok is True
assert response.checking_id == checking_id
assert response.fee_msat == 2000
assert response.preimage == "preimage"
assert checking_id not in bark_wallet.outgoing_payment_waiters
@pytest.mark.anyio
@pytest.mark.parametrize(
"error",
[
httpx.TimeoutException("timeout"),
httpx.ReadError("connection lost"),
],
ids=["timeout", "read-error"],
)
async def test_send_payment_keeps_transport_errors_pending(
bark_wallet: BarkWallet, mocker, error: httpx.RequestError
):
checking_id = bolt11_decode(BOLT11).payment_hash
mocker.patch.object(bark_wallet.client, "post", side_effect=error)
response = await bark_wallet._send_payment(BOLT11, checking_id)
assert response.pending
assert response.checking_id == checking_id
assert bark_wallet.pending_payments[checking_id] == BOLT11
@pytest.mark.anyio
@pytest.mark.parametrize(
("status_code", "expected_ok"),
[
(400, None),
(401, False),
(403, False),
(404, False),
(405, False),
(408, None),
(409, None),
(422, None),
(429, None),
(500, None),
],
)
async def test_send_payment_maps_http_errors(
bark_wallet: BarkWallet,
mocker,
status_code: int,
expected_ok: bool | None,
):
checking_id = bolt11_decode(BOLT11).payment_hash
mocker.patch.object(
bark_wallet.client,
"post",
return_value=payment_response(status_code, json={"message": "payment error"}),
)
response = await bark_wallet._send_payment(BOLT11, checking_id)
assert response.ok is expected_ok
assert response.checking_id == checking_id
assert (checking_id in bark_wallet.pending_payments) is (expected_ok is None)
@pytest.mark.anyio
@pytest.mark.parametrize(
"response",
[
payment_response(200, content=b"not json"),
payment_response(200, json={"unexpected": "response"}),
],
ids=["invalid-json", "missing-message"],
)
async def test_send_payment_keeps_invalid_responses_pending(
bark_wallet: BarkWallet, mocker, response: httpx.Response
):
checking_id = bolt11_decode(BOLT11).payment_hash
mocker.patch.object(bark_wallet.client, "post", return_value=response)
payment = await bark_wallet._send_payment(BOLT11, checking_id)
assert payment.pending
assert payment.checking_id == checking_id
assert bark_wallet.pending_payments[checking_id] == BOLT11