367 lines
11 KiB
Python
367 lines
11 KiB
Python
from collections.abc import Callable
|
|
from typing import Any, cast
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from pytest_mock.plugin import MockerFixture
|
|
|
|
from lnbits.core.views import node_api
|
|
from lnbits.db import Filters, Page
|
|
from lnbits.nodes.base import (
|
|
ChannelBalance,
|
|
ChannelPoint,
|
|
ChannelState,
|
|
ChannelStats,
|
|
Node,
|
|
NodeChannel,
|
|
NodeFees,
|
|
NodeInfoResponse,
|
|
NodeInvoice,
|
|
NodePayment,
|
|
NodePeerInfo,
|
|
PublicNodeInfo,
|
|
)
|
|
from lnbits.settings import Settings
|
|
from lnbits.wallets.base import Feature
|
|
|
|
|
|
class FakeNode:
|
|
def __init__(self):
|
|
self.channel = NodeChannel(
|
|
id="chan-1",
|
|
short_id="123x1x0",
|
|
peer_id="peer-1",
|
|
name="Peer One",
|
|
color="#ffffff",
|
|
state=ChannelState.ACTIVE,
|
|
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
|
|
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
|
|
fee_ppm=10,
|
|
fee_base_msat=1000,
|
|
)
|
|
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
|
|
self.info = NodeInfoResponse(
|
|
id="node-id",
|
|
backend_name="FakeNode",
|
|
alias="Fake Alias",
|
|
color="#ffffff",
|
|
num_peers=1,
|
|
blockheight=1,
|
|
channel_stats=ChannelStats(
|
|
counts={ChannelState.ACTIVE: 1},
|
|
avg_size=3000,
|
|
biggest_size=3000,
|
|
smallest_size=3000,
|
|
total_capacity=3000,
|
|
),
|
|
addresses=["127.0.0.1:9735"],
|
|
onchain_balance_sat=1,
|
|
onchain_confirmed_sat=1,
|
|
fees=NodeFees(total_msat=0),
|
|
balance_msat=3000,
|
|
)
|
|
self.fees_updated: tuple[str, int | None, int | None] | None = None
|
|
|
|
async def get_public_info(self) -> PublicNodeInfo:
|
|
return PublicNodeInfo(**self.info.dict())
|
|
|
|
async def get_info(self) -> NodeInfoResponse:
|
|
return self.info
|
|
|
|
async def get_channels(self) -> list[NodeChannel]:
|
|
return [self.channel]
|
|
|
|
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
|
return self.channel if channel_id == self.channel.id else None
|
|
|
|
async def open_channel(
|
|
self,
|
|
peer_id: str,
|
|
funding_amount: int,
|
|
push_amount: int | None = None,
|
|
fee_rate: int | None = None,
|
|
) -> ChannelPoint:
|
|
assert peer_id == "peer-1"
|
|
assert funding_amount == 10_000
|
|
assert push_amount == 100
|
|
assert fee_rate == 5
|
|
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
|
|
|
|
async def close_channel(
|
|
self,
|
|
short_id: str | None = None,
|
|
point: ChannelPoint | None = None,
|
|
force: bool = False,
|
|
) -> list[NodeChannel]:
|
|
assert short_id == self.channel.short_id
|
|
assert point is None
|
|
assert force is True
|
|
return [self.channel]
|
|
|
|
async def set_channel_fee(
|
|
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
|
|
) -> None:
|
|
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
|
|
|
|
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
|
|
return Page(
|
|
data=[
|
|
NodePayment(
|
|
pending=False,
|
|
amount=1,
|
|
fee=0,
|
|
memo="payment",
|
|
time=1,
|
|
preimage="11" * 32,
|
|
payment_hash="22" * 32,
|
|
)
|
|
],
|
|
total=1,
|
|
)
|
|
|
|
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
|
|
return Page(
|
|
data=[
|
|
NodeInvoice(
|
|
pending=False,
|
|
amount=1,
|
|
memo="invoice",
|
|
bolt11="lnbc1dummy",
|
|
preimage="11" * 32,
|
|
payment_hash="33" * 32,
|
|
)
|
|
],
|
|
total=1,
|
|
)
|
|
|
|
async def get_peers(self) -> list[NodePeerInfo]:
|
|
return [self.peer]
|
|
|
|
async def connect_peer(self, uri: str) -> dict[str, str]:
|
|
return {"uri": uri}
|
|
|
|
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
|
|
return {"peer_id": peer_id}
|
|
|
|
async def get_id(self) -> str:
|
|
return "fake-node-id"
|
|
|
|
|
|
class FakeFundingSource:
|
|
def __init__(
|
|
self,
|
|
features: list[Feature],
|
|
node_factory: Callable[[Any], Any] | None,
|
|
):
|
|
self.features = features
|
|
self.__node_cls__ = node_factory
|
|
|
|
|
|
class MockHTTPResponse:
|
|
def __init__(
|
|
self, json_data: dict[str, Any], status_error: Exception | None = None
|
|
):
|
|
self._json_data = json_data
|
|
self._status_error = status_error
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self._status_error:
|
|
raise self._status_error
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self._json_data
|
|
|
|
|
|
class MockHTTPClient:
|
|
def __init__(self, response: MockHTTPResponse):
|
|
self.response = response
|
|
self.calls: list[str] = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def get(self, url: str, timeout: int):
|
|
self.calls.append(url)
|
|
return self.response
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
|
|
original_node_ui = settings.lnbits_node_ui
|
|
original_public = settings.lnbits_public_node_ui
|
|
try:
|
|
settings.lnbits_node_ui = True
|
|
funding_source = FakeFundingSource([], None)
|
|
mocker.patch(
|
|
"lnbits.core.views.node_api.get_funding_source",
|
|
return_value=funding_source,
|
|
)
|
|
with pytest.raises(HTTPException) as excinfo:
|
|
node_api.require_node()
|
|
assert excinfo.value.status_code == 501
|
|
|
|
node_enabled_source = FakeFundingSource(
|
|
[Feature.nodemanager], lambda wallet: "fake-node"
|
|
)
|
|
mocker.patch(
|
|
"lnbits.core.views.node_api.get_funding_source",
|
|
return_value=node_enabled_source,
|
|
)
|
|
settings.lnbits_node_ui = False
|
|
with pytest.raises(HTTPException) as disabled:
|
|
node_api.require_node()
|
|
assert disabled.value.status_code == 503
|
|
|
|
settings.lnbits_node_ui = True
|
|
assert node_api.require_node() == "fake-node"
|
|
|
|
settings.lnbits_public_node_ui = False
|
|
with pytest.raises(HTTPException) as public_disabled:
|
|
node_api.check_public()
|
|
assert public_disabled.value.status_code == 503
|
|
finally:
|
|
settings.lnbits_node_ui = original_node_ui
|
|
settings.lnbits_public_node_ui = original_public
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_node_api_route_functions_with_fake_node(
|
|
settings: Settings,
|
|
mocker: MockerFixture,
|
|
):
|
|
fake_node = FakeNode()
|
|
node = cast(Node, fake_node)
|
|
original_transactions = settings.lnbits_node_ui_transactions
|
|
settings.lnbits_node_ui_transactions = True
|
|
rank_response = MockHTTPResponse(
|
|
{
|
|
"noderank": {
|
|
"capacity": 1,
|
|
"channelcount": 2,
|
|
"age": 3,
|
|
"growth": 4,
|
|
"availability": 5,
|
|
}
|
|
}
|
|
)
|
|
mocker.patch(
|
|
"lnbits.core.views.node_api.httpx.AsyncClient",
|
|
return_value=MockHTTPClient(rank_response),
|
|
)
|
|
|
|
try:
|
|
assert await node_api.api_get_ok() is None
|
|
|
|
public_info = await node_api.api_get_public_info(node=node)
|
|
assert public_info.backend_name == "FakeNode"
|
|
|
|
info = await node_api.api_get_info(node=node)
|
|
assert info is not None
|
|
assert info.id == "node-id"
|
|
|
|
channels = await node_api.api_get_channels(node=node)
|
|
assert channels is not None
|
|
assert channels[0].id == "chan-1"
|
|
|
|
channel = await node_api.api_get_channel("chan-1", node=node)
|
|
assert channel is not None
|
|
assert channel.peer_id == "peer-1"
|
|
|
|
created = await node_api.api_create_channel(
|
|
node=node,
|
|
peer_id="peer-1",
|
|
funding_amount=10_000,
|
|
push_amount=100,
|
|
fee_rate=5,
|
|
)
|
|
assert created.output_index == 0
|
|
|
|
deleted = await node_api.api_delete_channel(
|
|
short_id="123x1x0",
|
|
funding_txid=None,
|
|
output_index=None,
|
|
force=True,
|
|
node=node,
|
|
)
|
|
assert deleted is not None
|
|
assert deleted[0].id == "chan-1"
|
|
|
|
await node_api.api_set_channel_fees(
|
|
"chan-1",
|
|
node=node,
|
|
fee_ppm=42,
|
|
fee_base_msat=7,
|
|
)
|
|
assert fake_node.fees_updated == ("chan-1", 7, 42)
|
|
|
|
payments = await node_api.api_get_payments(node=node, filters=Filters())
|
|
assert payments is not None
|
|
assert payments.total == 1
|
|
|
|
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
|
|
assert invoices is not None
|
|
assert invoices.total == 1
|
|
|
|
peers = await node_api.api_get_peers(node=node)
|
|
assert peers[0].id == "peer-1"
|
|
|
|
connect = await node_api.api_connect_peer(
|
|
uri="peer-1@127.0.0.1:9735", node=node
|
|
)
|
|
assert connect["uri"] == "peer-1@127.0.0.1:9735"
|
|
|
|
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
|
|
assert disconnect["peer_id"] == "peer-1"
|
|
|
|
rank = await node_api.api_get_1ml_stats(node=node)
|
|
assert rank is not None
|
|
rank_data = node_api.NodeRank.parse_obj(rank)
|
|
assert rank_data.channelcount == 2
|
|
finally:
|
|
settings.lnbits_node_ui_transactions = original_transactions
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_node_api_transactions_and_rank_errors(
|
|
settings: Settings,
|
|
mocker: MockerFixture,
|
|
):
|
|
fake_node = FakeNode()
|
|
node = cast(Node, fake_node)
|
|
original_transactions = settings.lnbits_node_ui_transactions
|
|
settings.lnbits_node_ui_transactions = False
|
|
|
|
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
|
|
mocker.patch(
|
|
"lnbits.core.views.node_api.httpx.AsyncClient",
|
|
return_value=MockHTTPClient(
|
|
MockHTTPResponse(
|
|
{},
|
|
status_error=httpx.HTTPStatusError(
|
|
"not found", request=request, response=httpx.Response(404)
|
|
),
|
|
)
|
|
),
|
|
)
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as payments:
|
|
await node_api.api_get_payments(node=node, filters=Filters())
|
|
assert payments.value.status_code == 503
|
|
|
|
with pytest.raises(HTTPException) as invoices:
|
|
await node_api.api_get_invoices(node=node, filters=Filters())
|
|
assert invoices.value.status_code == 503
|
|
|
|
with pytest.raises(HTTPException) as rank:
|
|
await node_api.api_get_1ml_stats(node=node)
|
|
assert rank.value.status_code == 404
|
|
assert rank.value.detail == "Node not found on 1ml.com"
|
|
finally:
|
|
settings.lnbits_node_ui_transactions = original_transactions
|