[test] create unit-test framework for RPC wallets (#2396)

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan 2024-04-15 18:24:28 +03:00 committed by GitHub
parent b145bff566
commit 69ce0e565b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2128 additions and 270 deletions

Binary file not shown.

View file

@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIFbzCCA1egAwIBAgIUfkee1G4E8QAadd517sY/9+6xr0AwDQYJKoZIhvcNAQEL
BQAwRjELMAkGA1UEBhMCU1YxFDASBgNVBAgMC0VsIFNhbHZhZG9yMSEwHwYDVQQK
DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwNDAzMTMyMTM5WhgPMjA1
MTA4MjAxMzIxMzlaMEYxCzAJBgNVBAYTAlNWMRQwEgYDVQQIDAtFbCBTYWx2YWRv
cjEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEAnW4MKs2Y3qZnn2+J/Bp21aUuJ7oE8ll82Q2C
uh8VAlsNnGDpTyOSRLHLmxV+cu82umvVPBpOVwAl17/VuxcLjFVSk7YOMj3MWoF5
hm+oBtetouSDt3H0+BoDuXN3eVsLI4b+e1F6ag7JIwsDQvRUbGTFiyHVvXolTZPb
wtFzlwQSB5i6KHKRQ+W6Q+cz4khIRO79IhaEiu5TWDrmx+6WkZxWYYO/g/I/S1gX
l1JP6gXQFabwUFn+CBAxPsi7f+igi6gIepXBQOIG1dkZ5ojJPabtvblO7mWJTsec
2D4Vb3L7OfboIYC85gY1cudWBX3oAASIVh9m9YoCZW2WOMNr6apnJSXx36ueJXAS
rPq3C2haPWO8z+0nYkaYTcTAxeCvs0ux2DGIniinC+u1cELg6REK2X1K8YsSsXrc
U1T8rNs2azyzTxglIHHac6ScG+Ac1nlY54C9UfZZcztE8nUBqJi+Eowpyr+y3QvT
zNdulc80xpi5arbzt85BNi+xX+NZC07QjgUJ/eexRglP3flfTbbnG8Pphe/M/l04
IfBWBqK2cF9Fd+1J+Zf7fXZrw+41QF8WukLoQ4JQEMqIIhDFzaoTi5ogsnhiGu0Z
iaCATfCLMsWvAPHw6afFw2/utdvCd2Dr22H16hj0xEkNOw702/AoNWMFmzIzuC9m
VjkH1KUCAwEAAaNTMFEwHQYDVR0OBBYEFJAQIGLZNVRwGIgb3cmPTAiduzreMB8G
A1UdIwQYMBaAFJAQIGLZNVRwGIgb3cmPTAiduzreMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggIBAFOaWcLZSU46Zr43kQU+w+A70r+unmRfsANeREDi
Qvjg1ihJLO8g1l7Cu74QUqLwx8BG3KO7ZbDcN6uTeCrYgyERSVUxNAwu5hf2LnEr
MQ/L4h0j/8flj9oowTDCit/6YXTJ1Mf8OaKkSliUYVsoZCaIISZ2pvcZbU1cXCeX
JBM4Zr1ijM8qbghPoG6O7Ep/A3VHTozuAU9C7uREH+XJFepr9BXjrFqyzx/ArEZa
5HIO9nOqWqtwMFDE2jX3Ios3tjbU275ez2Xd7meDn0iPWMEgNbXX6b+FFlNkajR2
NchPmBigBpk9bt63HeIQb2t/VU7X9FvMTqCbp1R2MGiHTMyQ9IjeoYKNy/mur/GG
DQkG7rq52oPGI06CJ7uuMEhCm6jNVtIbnCTl2jRnkD1fqKVmQa9Cn7jqDqR2dhqX
AxTk01Vhinxhik0ckhcgViRgiBWSnnx4Vzk7wyV6O4EdtLTywkywTR/+WEisBVUV
LOXZEmxj+AVARARUds+a/IgdANFGr/yWI6WBOibjoEFZMEZqzwlcEErgxLRinUvb
9COmr6ig+zC1570V2ktmn1P/qodOD4tOL0ICSkKoTQLFPfevM2y0DdN48T2kxzZ5
TruiKHuAnOhvwKwUpF+TRFMUWft3VG9GJXm/4A9FWm/ALLrqw2oSXGrl5z8pq29z
SN2A
-----END CERTIFICATE-----

View file

@ -4,7 +4,8 @@
"wallet_class": "CoreLightningRestWallet",
"settings": {
"corelightning_rest_url": "http://127.0.0.1:8555",
"corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn"
"corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn",
"user_agent": "LNbits/Tests"
}
},
"lndrest": {
@ -12,14 +13,16 @@
"settings": {
"lnd_rest_endpoint": "http://127.0.0.1:8555",
"lnd_rest_macaroon": "eNcRyPtEdMaCaRoOn",
"lnd_rest_cert": ""
"lnd_rest_cert": "",
"user_agent": "LNbits/Tests"
}
},
"alby": {
"wallet_class": "AlbyWallet",
"settings": {
"alby_api_endpoint": "http://127.0.0.1:8555",
"alby_access_token": "mock-alby-access-token"
"alby_access_token": "mock-alby-access-token",
"user_agent": "LNbits/Tests"
}
}
},

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
from typing import Dict, List, Optional, Union
from pydantic import BaseModel
class FundingSourceConfig(BaseModel):
name: str
skip: Optional[bool]
wallet_class: str
client_field: Optional[str]
settings: dict
class FunctionMock(BaseModel):
uri: Optional[str]
query_params: Optional[dict]
headers: Optional[dict]
method: Optional[str]
class TestMock(BaseModel):
skip: Optional[bool]
description: Optional[str]
request_type: Optional[str]
request_body: Optional[dict]
response_type: str
response: Union[str, dict]
class Mock(FunctionMock, TestMock):
@staticmethod
def combine_mocks(fs_mock, test_mock):
_mock = fs_mock | test_mock
if "response" in _mock and "response" in fs_mock:
_mock["response"] |= fs_mock["response"]
return Mock(**_mock)
class FunctionMocks(BaseModel):
mocks: Dict[str, FunctionMock]
class FunctionTest(BaseModel):
description: str
call_params: dict
expect: dict
mocks: Dict[str, List[Dict[str, TestMock]]]
class FunctionData(BaseModel):
"""Data required for testing this function"""
"Function level mocks that apply for all tests of this function"
mocks: List[FunctionMock] = []
"All the tests for this function"
tests: List[FunctionTest] = []
class WalletTest(BaseModel):
skip: Optional[bool]
function: str
description: str
funding_source: FundingSourceConfig
call_params: Optional[dict] = {}
expect: Optional[dict]
expect_error: Optional[dict]
mocks: List[Mock] = []
@staticmethod
def tests_for_funding_source(
fs: FundingSourceConfig,
fn_name: str,
fn,
test,
) -> List["WalletTest"]:
t = WalletTest(
**{
"funding_source": fs,
"function": fn_name,
**test,
"mocks": [],
"skip": fs.skip,
}
)
if "mocks" in test:
if fs.name not in test["mocks"]:
t.skip = True
return [t]
return t._tests_from_fs_mocks(fn, test, fs.name)
return [t]
def _tests_from_fs_mocks(self, fn, test, fs_name: str) -> List["WalletTest"]:
tests: List[WalletTest] = []
fs_mocks = fn["mocks"][fs_name]
test_mocks = test["mocks"][fs_name]
for mock_name in fs_mocks:
tests += self._tests_from_mocks(fs_mocks[mock_name], test_mocks[mock_name])
return tests
def _tests_from_mocks(self, fs_mock, test_mocks) -> List["WalletTest"]:
tests: List[WalletTest] = []
for test_mock in test_mocks:
# different mocks that result in the same
# return value for the tested function
unique_test = self._test_from_mocks(fs_mock, test_mock)
tests.append(unique_test)
return tests
def _test_from_mocks(self, fs_mock, test_mock) -> "WalletTest":
mock = Mock.combine_mocks(fs_mock, test_mock)
return WalletTest(
**(
self.dict()
| {
"description": f"""{self.description}:{mock.description or ""}""",
"mocks": self.mocks + [mock],
"skip": self.skip or mock.skip,
}
)
)
class DataObject:
def __init__(self, **kwargs):
for k in kwargs:
setattr(self, k, kwargs[k])

117
tests/wallets/helpers.py Normal file
View file

@ -0,0 +1,117 @@
import importlib
import json
from typing import Dict, List
import pytest
from lnbits.core.models import BaseWallet
from tests.wallets.fixtures.models import FundingSourceConfig, WalletTest
wallets_module = importlib.import_module("lnbits.wallets")
def wallet_fixtures_from_json(path) -> List["WalletTest"]:
with open(path) as f:
data = json.load(f)
funding_sources = [
FundingSourceConfig(name=fs_name, **data["funding_sources"][fs_name])
for fs_name in data["funding_sources"]
]
tests: Dict[str, List[WalletTest]] = {}
for fn_name in data["functions"]:
fn = data["functions"][fn_name]
fn_tests = _tests_for_function(funding_sources, fn_name, fn)
_merge_dict_of_lists(tests, fn_tests)
all_tests = sum([tests[fs_name] for fs_name in tests], [])
return all_tests
def _tests_for_function(
funding_sources: List[FundingSourceConfig], fn_name: str, fn
) -> Dict[str, List[WalletTest]]:
tests: Dict[str, List[WalletTest]] = {}
for test in fn["tests"]:
"""create an unit test for each funding source"""
fs_tests = _tests_for_funding_source(funding_sources, fn_name, fn, test)
_merge_dict_of_lists(tests, fs_tests)
return tests
def _tests_for_funding_source(
funding_sources: List[FundingSourceConfig], fn_name: str, fn, test
) -> Dict[str, List[WalletTest]]:
tests: Dict[str, List[WalletTest]] = {fs.name: [] for fs in funding_sources}
for fs in funding_sources:
tests[fs.name] += WalletTest.tests_for_funding_source(fs, fn_name, fn, test)
return tests
def build_test_id(test: WalletTest):
return f"{test.funding_source}.{test.function}({test.description})"
def load_funding_source(funding_source: FundingSourceConfig) -> BaseWallet:
custom_settings = funding_source.settings
original_settings = {}
settings = getattr(wallets_module, "settings")
for s in custom_settings:
original_settings[s] = getattr(settings, s)
setattr(settings, s, custom_settings[s])
fs_instance: BaseWallet = getattr(wallets_module, funding_source.wallet_class)()
# rollback settings (global variable)
for s in original_settings:
setattr(settings, s, original_settings[s])
return fs_instance
async def check_assertions(wallet, _test_data: WalletTest):
test_data = _test_data.dict()
tested_func = _test_data.function
call_params = _test_data.call_params
if "expect" in test_data:
await _assert_data(wallet, tested_func, call_params, _test_data.expect)
# if len(_test_data.mocks) == 0:
# # all calls should fail after this method is called
# await wallet.cleanup()
# # same behaviour expected is server canot be reached
# # or if the connection was closed
# await _assert_data(wallet, tested_func, call_params, _test_data.expect)
elif "expect_error" in test_data:
await _assert_error(wallet, tested_func, call_params, _test_data.expect_error)
else:
assert False, "Expected outcome not specified"
async def _assert_data(wallet, tested_func, call_params, expect):
resp = await getattr(wallet, tested_func)(**call_params)
for key in expect:
received = getattr(resp, key)
expected = expect[key]
assert (
getattr(resp, key) == expect[key]
), f"""Field "{key}". Received: "{received}". Expected: "{expected}"."""
async def _assert_error(wallet, tested_func, call_params, expect_error):
error_module = importlib.import_module(expect_error["module"])
error_class = getattr(error_module, expect_error["class"])
with pytest.raises(error_class) as e_info:
await getattr(wallet, tested_func)(**call_params)
assert e_info.match(expect_error["message"])
def _merge_dict_of_lists(v1: Dict[str, List], v2: Dict[str, List]):
"""Merge v2 into v1"""
for k in v2:
v1[k] = v2[k] if k not in v1 else v1[k] + v2[k]

View file

@ -1,4 +1,3 @@
import importlib
import json
from typing import Dict, Union
from urllib.parse import urlencode
@ -7,16 +6,15 @@ import pytest
from pytest_httpserver import HTTPServer
from werkzeug.wrappers import Response
from lnbits.core.models import BaseWallet
from tests.helpers import (
FundingSourceConfig,
Mock,
from tests.wallets.fixtures.models import Mock
from tests.wallets.helpers import (
WalletTest,
rest_wallet_fixtures_from_json,
build_test_id,
check_assertions,
load_funding_source,
wallet_fixtures_from_json,
)
wallets_module = importlib.import_module("lnbits.wallets")
# todo:
# - tests for extra fields
# - tests for paid_invoices_stream
@ -29,14 +27,10 @@ def httpserver_listen_address():
return ("127.0.0.1", 8555)
def build_test_id(test: WalletTest):
return f"{test.funding_source}.{test.function}({test.description})"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_data",
rest_wallet_fixtures_from_json("tests/wallets/fixtures.json"),
wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_rest.json"),
ids=build_test_id,
)
async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest):
@ -46,8 +40,8 @@ async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest):
for mock in test_data.mocks:
_apply_mock(httpserver, mock)
wallet = _load_funding_source(test_data.funding_source)
await _check_assertions(wallet, test_data)
wallet = load_funding_source(test_data.funding_source)
await check_assertions(wallet, test_data)
def _apply_mock(httpserver: HTTPServer, mock: Mock):
@ -65,6 +59,8 @@ def _apply_mock(httpserver: HTTPServer, mock: Mock):
if mock.query_params:
request_data["query_string"] = mock.query_params
assert mock.uri, "Missing URI for HTTP mock."
assert mock.method, "Missing method for HTTP mock."
req = httpserver.expect_request(
uri=mock.uri,
headers=mock.headers,
@ -84,60 +80,3 @@ def _apply_mock(httpserver: HTTPServer, mock: Mock):
respond_with = f"respond_with_{response_type}"
getattr(req, respond_with)(server_response)
async def _check_assertions(wallet, _test_data: WalletTest):
test_data = _test_data.dict()
tested_func = _test_data.function
call_params = _test_data.call_params
if "expect" in test_data:
await _assert_data(wallet, tested_func, call_params, _test_data.expect)
# if len(_test_data.mocks) == 0:
# # all calls should fail after this method is called
# await wallet.cleanup()
# # same behaviour expected is server canot be reached
# # or if the connection was closed
# await _assert_data(wallet, tested_func, call_params, _test_data.expect)
elif "expect_error" in test_data:
await _assert_error(wallet, tested_func, call_params, _test_data.expect_error)
else:
assert False, "Expected outcome not specified"
async def _assert_data(wallet, tested_func, call_params, expect):
resp = await getattr(wallet, tested_func)(**call_params)
for key in expect:
received = getattr(resp, key)
expected = expect[key]
assert (
getattr(resp, key) == expect[key]
), f"""Field "{key}". Received: "{received}". Expected: "{expected}"."""
async def _assert_error(wallet, tested_func, call_params, expect_error):
error_module = importlib.import_module(expect_error["module"])
error_class = getattr(error_module, expect_error["class"])
with pytest.raises(error_class) as e_info:
await getattr(wallet, tested_func)(**call_params)
assert e_info.match(expect_error["message"])
def _load_funding_source(funding_source: FundingSourceConfig) -> BaseWallet:
custom_settings = funding_source.settings | {"user_agent": "LNbits/Tests"}
original_settings = {}
settings = getattr(wallets_module, "settings")
for s in custom_settings:
original_settings[s] = getattr(settings, s)
setattr(settings, s, custom_settings[s])
fs_instance: BaseWallet = getattr(wallets_module, funding_source.wallet_class)()
# rollback settings (global variable)
for s in original_settings:
setattr(settings, s, original_settings[s])
return fs_instance

View file

@ -0,0 +1,145 @@
import importlib
from typing import Dict, List, Optional
import pytest
from mock import Mock
from pytest_mock.plugin import MockerFixture
from lnbits.core.models import BaseWallet
from tests.wallets.fixtures.models import DataObject
from tests.wallets.fixtures.models import Mock as RpcMock
from tests.wallets.helpers import (
WalletTest,
build_test_id,
check_assertions,
load_funding_source,
wallet_fixtures_from_json,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_data",
wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_rpc.json"),
ids=build_test_id,
)
async def test_wallets(mocker: MockerFixture, test_data: WalletTest):
if test_data.skip:
pytest.skip()
for mock in test_data.mocks:
_apply_rpc_mock(mocker, mock)
wallet = load_funding_source(test_data.funding_source)
expected_calls = _spy_mocks(mocker, test_data, wallet)
await check_assertions(wallet, test_data)
_check_calls(expected_calls)
def _apply_rpc_mock(mocker: MockerFixture, mock: RpcMock):
return_value = {}
assert isinstance(mock.response, dict), "Expected data RPC response"
for field_name in mock.response:
value = mock.response[field_name]
values = value if isinstance(value, list) else [value]
return_value[field_name] = Mock(side_effect=[_mock_field(f) for f in values])
m = _data_mock(return_value)
assert mock.method, "Missing method for RPC mock."
mocker.patch(mock.method, m)
def _check_calls(expected_calls):
for func in expected_calls:
func_calls = expected_calls[func]
for func_call in func_calls:
req = func_call["request_data"]
args = req["args"] if "args" in req else {}
kwargs = req["kwargs"] if "kwargs" in req else {}
if "klass" in req:
*rest, cls = req["klass"].split(".")
req_module = importlib.import_module(".".join(rest))
req_class = getattr(req_module, cls)
func_call["spy"].assert_called_with(req_class(*args, **kwargs))
else:
func_call["spy"].assert_called_with(*args, **kwargs)
def _spy_mocks(mocker: MockerFixture, test_data: WalletTest, wallet: BaseWallet):
assert (
test_data.funding_source.client_field
), f"Missing client field for wallet {wallet}"
client_field = getattr(wallet, test_data.funding_source.client_field)
expected_calls: Dict[str, List] = {}
for mock in test_data.mocks:
spy = _spy_mock(mocker, mock, client_field)
expected_calls |= spy
return expected_calls
def _spy_mock(mocker: MockerFixture, mock: RpcMock, client_field):
expected_calls: Dict[str, List] = {}
assert isinstance(mock.response, dict), "Expected data RPC response"
for field_name in mock.response:
value = mock.response[field_name]
values = value if isinstance(value, list) else [value]
expected_calls[field_name] = [
{
"spy": mocker.spy(client_field, field_name),
"request_data": f["request_data"],
}
for f in values
if f["request_type"] == "function" and "request_data" in f
]
return expected_calls
def _mock_field(field):
response_type = field["response_type"]
request_type = field["request_type"]
response = field["response"]
if request_type == "data":
return _dict_to_object(response)
if request_type == "function":
if response_type == "data":
return _dict_to_object(response)
if response_type == "exception":
return _raise(response)
return response
def _dict_to_object(data: Optional[dict]) -> Optional[DataObject]:
if not data:
return None
d = {**data}
for k in data:
value = data[k]
if isinstance(value, dict):
d[k] = _dict_to_object(value)
return DataObject(**d)
def _data_mock(data: dict) -> Mock:
return Mock(return_value=_dict_to_object(data))
def _raise(error: dict):
data = error["data"] if "data" in error else None
if "module" not in error or "class" not in error:
return Exception(data)
error_module = importlib.import_module(error["module"])
error_class = getattr(error_module, error["class"])
return error_class(**data)