mirror of
https://github.com/lnbits/lnbits.git
synced 2025-06-06 05:01:21 +02:00
[test] create unit-test framework for RPC wallets (#2396)
---------
Co-authored-by: dni ⚡ <office@dnilabs.com>
This commit is contained in:
parent
b145bff566
commit
69ce0e565b
@ -40,6 +40,9 @@
|
|||||||
pytest-md = prev.pytest-md.overridePythonAttrs (
|
pytest-md = prev.pytest-md.overridePythonAttrs (
|
||||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||||
);
|
);
|
||||||
|
types-mock = prev.pytest-md.overridePythonAttrs (
|
||||||
|
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -54,12 +54,19 @@ class CoreLightningWallet(Wallet):
|
|||||||
async def status(self) -> StatusResponse:
|
async def status(self) -> StatusResponse:
|
||||||
try:
|
try:
|
||||||
funds: dict = self.ln.listfunds() # type: ignore
|
funds: dict = self.ln.listfunds() # type: ignore
|
||||||
|
if len(funds) == 0:
|
||||||
|
return StatusResponse("no data", 0)
|
||||||
|
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
None, sum([int(ch["our_amount_msat"]) for ch in funds["channels"]])
|
None, sum([int(ch["our_amount_msat"]) for ch in funds["channels"]])
|
||||||
)
|
)
|
||||||
except RpcError as exc:
|
except RpcError as exc:
|
||||||
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
|
logger.warning(exc)
|
||||||
|
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
|
||||||
return StatusResponse(error_message, 0)
|
return StatusResponse(error_message, 0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to connect, got: '{exc}'")
|
||||||
|
return StatusResponse(f"Unable to connect, got: '{exc}'", 0)
|
||||||
|
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
self,
|
self,
|
||||||
@ -69,7 +76,7 @@ class CoreLightningWallet(Wallet):
|
|||||||
unhashed_description: Optional[bytes] = None,
|
unhashed_description: Optional[bytes] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> InvoiceResponse:
|
) -> InvoiceResponse:
|
||||||
label = f"lbl{random.random()}"
|
label = kwargs.get("label", f"lbl{random.random()}")
|
||||||
msat: int = int(amount * 1000)
|
msat: int = int(amount * 1000)
|
||||||
try:
|
try:
|
||||||
if description_hash and not unhashed_description:
|
if description_hash and not unhashed_description:
|
||||||
@ -95,14 +102,18 @@ class CoreLightningWallet(Wallet):
|
|||||||
if r.get("code") and r.get("code") < 0: # type: ignore
|
if r.get("code") and r.get("code") < 0: # type: ignore
|
||||||
raise Exception(r.get("message"))
|
raise Exception(r.get("message"))
|
||||||
|
|
||||||
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
|
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], None)
|
||||||
except RpcError as exc:
|
except RpcError as exc:
|
||||||
error_message = (
|
logger.warning(exc)
|
||||||
f"CoreLightning method '{exc.method}' failed with"
|
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
|
||||||
f" '{exc.error.get('message') or exc.error}'." # type: ignore
|
|
||||||
)
|
|
||||||
return InvoiceResponse(False, None, None, error_message)
|
return InvoiceResponse(False, None, None, error_message)
|
||||||
|
except KeyError as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return InvoiceResponse(
|
||||||
|
False, None, None, "Server error: 'missing required fields'"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
return InvoiceResponse(False, None, None, str(e))
|
return InvoiceResponse(False, None, None, str(e))
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
@ -111,94 +122,111 @@ class CoreLightningWallet(Wallet):
|
|||||||
except Bolt11Exception as exc:
|
except Bolt11Exception as exc:
|
||||||
return PaymentResponse(False, None, None, None, str(exc))
|
return PaymentResponse(False, None, None, None, str(exc))
|
||||||
|
|
||||||
previous_payment = await self.get_payment_status(invoice.payment_hash)
|
|
||||||
if previous_payment.paid:
|
|
||||||
return PaymentResponse(False, None, None, None, "invoice already paid")
|
|
||||||
|
|
||||||
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
|
||||||
return PaymentResponse(
|
|
||||||
False, None, None, None, "CLN 0 amount invoice not supported"
|
|
||||||
)
|
|
||||||
|
|
||||||
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
|
||||||
# so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi
|
|
||||||
# (which is default value of exemptfee)
|
|
||||||
payload = {
|
|
||||||
"bolt11": bolt11,
|
|
||||||
"maxfeepercent": f"{fee_limit_percent:.11}",
|
|
||||||
"exemptfee": 0,
|
|
||||||
# so fee_limit_percent is applied even on payments with fee < 5000
|
|
||||||
# millisatoshi (which is default value of exemptfee)
|
|
||||||
"description": invoice.description,
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
|
previous_payment = await self.get_payment_status(invoice.payment_hash)
|
||||||
|
if previous_payment.paid:
|
||||||
|
return PaymentResponse(False, None, None, None, "invoice already paid")
|
||||||
|
|
||||||
|
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||||
|
return PaymentResponse(
|
||||||
|
False, None, None, None, "CLN 0 amount invoice not supported"
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||||
|
# so fee_limit_percent is applied even
|
||||||
|
# on payments with fee < 5000 millisatoshi
|
||||||
|
# (which is default value of exemptfee)
|
||||||
|
payload = {
|
||||||
|
"bolt11": bolt11,
|
||||||
|
"maxfeepercent": f"{fee_limit_percent:.11}",
|
||||||
|
"exemptfee": 0,
|
||||||
|
# so fee_limit_percent is applied even on payments with fee < 5000
|
||||||
|
# millisatoshi (which is default value of exemptfee)
|
||||||
|
"description": invoice.description,
|
||||||
|
}
|
||||||
|
|
||||||
r = await run_sync(lambda: self.ln.call("pay", payload))
|
r = await run_sync(lambda: self.ln.call("pay", payload))
|
||||||
|
|
||||||
|
fee_msat = -int(r["amount_sent_msat"] - r["amount_msat"])
|
||||||
|
return PaymentResponse(
|
||||||
|
True, r["payment_hash"], fee_msat, r["payment_preimage"], None
|
||||||
|
)
|
||||||
except RpcError as exc:
|
except RpcError as exc:
|
||||||
|
logger.warning(exc)
|
||||||
try:
|
try:
|
||||||
error_message = exc.error["attempts"][-1]["fail_reason"] # type: ignore
|
error_message = exc.error["attempts"][-1]["fail_reason"] # type: ignore
|
||||||
except Exception:
|
except Exception:
|
||||||
error_message = (
|
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
|
||||||
f"CoreLightning method '{exc.method}' failed with"
|
|
||||||
f" '{exc.error.get('message') or exc.error}'." # type: ignore
|
|
||||||
)
|
|
||||||
return PaymentResponse(False, None, None, None, error_message)
|
return PaymentResponse(False, None, None, None, error_message)
|
||||||
|
except KeyError as exc:
|
||||||
fee_msat = -int(r["amount_sent_msat"] - r["amount_msat"])
|
logger.warning(exc)
|
||||||
return PaymentResponse(
|
return PaymentResponse(
|
||||||
True, r["payment_hash"], fee_msat, r["payment_preimage"], None
|
False, None, None, None, "Server error: 'missing required fields'"
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info(f"Failed to pay invoice {bolt11}")
|
||||||
|
logger.warning(exc)
|
||||||
|
return PaymentResponse(False, None, None, None, f"Payment failed: '{exc}'.")
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore
|
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore
|
||||||
except RpcError:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
if not r["invoices"]:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
|
|
||||||
invoice_resp = r["invoices"][-1]
|
if not r["invoices"]:
|
||||||
|
|
||||||
if invoice_resp["payment_hash"] == checking_id:
|
|
||||||
if invoice_resp["status"] == "paid":
|
|
||||||
return PaymentSuccessStatus()
|
|
||||||
elif invoice_resp["status"] == "unpaid":
|
|
||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
elif invoice_resp["status"] == "expired":
|
|
||||||
return PaymentFailedStatus()
|
invoice_resp = r["invoices"][-1]
|
||||||
else:
|
|
||||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
if invoice_resp["payment_hash"] == checking_id:
|
||||||
return PaymentPendingStatus()
|
if invoice_resp["status"] == "paid":
|
||||||
|
return PaymentSuccessStatus()
|
||||||
|
elif invoice_resp["status"] == "unpaid":
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
elif invoice_resp["status"] == "expired":
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
else:
|
||||||
|
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
except RpcError as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
r: dict = self.ln.listpays(payment_hash=checking_id) # type: ignore
|
r: dict = self.ln.listpays(payment_hash=checking_id) # type: ignore
|
||||||
except Exception:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
if "pays" not in r:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
if not r["pays"]:
|
|
||||||
# no payment with this payment_hash is found
|
|
||||||
return PaymentFailedStatus()
|
|
||||||
|
|
||||||
payment_resp = r["pays"][-1]
|
if "pays" not in r:
|
||||||
|
|
||||||
if payment_resp["payment_hash"] == checking_id:
|
|
||||||
status = payment_resp["status"]
|
|
||||||
if status == "complete":
|
|
||||||
fee_msat = -int(
|
|
||||||
payment_resp["amount_sent_msat"] - payment_resp["amount_msat"]
|
|
||||||
)
|
|
||||||
|
|
||||||
return PaymentSuccessStatus(
|
|
||||||
fee_msat=fee_msat, preimage=payment_resp["preimage"]
|
|
||||||
)
|
|
||||||
elif status == "failed":
|
|
||||||
return PaymentFailedStatus()
|
|
||||||
else:
|
|
||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
else:
|
if not r["pays"]:
|
||||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
# no payment with this payment_hash is found
|
||||||
return PaymentPendingStatus()
|
return PaymentFailedStatus()
|
||||||
|
|
||||||
|
payment_resp = r["pays"][-1]
|
||||||
|
|
||||||
|
if payment_resp["payment_hash"] == checking_id:
|
||||||
|
status = payment_resp["status"]
|
||||||
|
if status == "complete":
|
||||||
|
fee_msat = -int(
|
||||||
|
payment_resp["amount_sent_msat"] - payment_resp["amount_msat"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return PaymentSuccessStatus(
|
||||||
|
fee_msat=fee_msat, preimage=payment_resp["preimage"]
|
||||||
|
)
|
||||||
|
elif status == "failed":
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
else:
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
else:
|
||||||
|
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
while True:
|
while True:
|
||||||
|
46
poetry.lock
generated
46
poetry.lock
generated
@ -1364,6 +1364,22 @@ docs = ["alabaster (==0.7.13)", "autodocsumm (==0.2.11)", "sphinx (==7.0.1)", "s
|
|||||||
lint = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)"]
|
lint = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)"]
|
||||||
tests = ["pytest", "pytz", "simplejson"]
|
tests = ["pytest", "pytz", "simplejson"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mock"
|
||||||
|
version = "5.1.0"
|
||||||
|
description = "Rolling backport of unittest.mock for all Pythons"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
files = [
|
||||||
|
{file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"},
|
||||||
|
{file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
build = ["blurb", "twine", "wheel"]
|
||||||
|
docs = ["sphinx"]
|
||||||
|
test = ["pytest", "pytest-cov"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.7.1"
|
version = "1.7.1"
|
||||||
@ -1996,6 +2012,23 @@ files = [
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
pytest = ">=4.2.1"
|
pytest = ">=4.2.1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-mock"
|
||||||
|
version = "3.14.0"
|
||||||
|
description = "Thin-wrapper around the mock package for easier use with pytest"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"},
|
||||||
|
{file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
pytest = ">=6.2.5"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["pre-commit", "pytest-asyncio", "tox"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-crontab"
|
name = "python-crontab"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
@ -2592,6 +2625,17 @@ notebook = ["ipywidgets (>=6)"]
|
|||||||
slack = ["slack-sdk"]
|
slack = ["slack-sdk"]
|
||||||
telegram = ["requests"]
|
telegram = ["requests"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-mock"
|
||||||
|
version = "5.1.0.20240311"
|
||||||
|
description = "Typing stubs for mock"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "types-mock-5.1.0.20240311.tar.gz", hash = "sha256:7472797986d83016f96fde7f73577d129b0cd8a8d0b783487a7be330d57ba431"},
|
||||||
|
{file = "types_mock-5.1.0.20240311-py3-none-any.whl", hash = "sha256:0769cb376dfc75b45215619f17a9fd6333d771cc29ce4a38937f060b1e45530f"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-passlib"
|
name = "types-passlib"
|
||||||
version = "1.7.7.13"
|
version = "1.7.7.13"
|
||||||
@ -3013,4 +3057,4 @@ liquid = ["wallycore"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.10 | ^3.9"
|
python-versions = "^3.10 | ^3.9"
|
||||||
content-hash = "4c11cc117beb703ebece5fac43adbabae76804f084c39ef90a67edcfb56795d7"
|
content-hash = "fd9ace1dada06a9a4556ffe888c9c391d1da4e2febd22084b6f53e6006eefa6e"
|
||||||
|
@ -74,6 +74,9 @@ json5 = "^0.9.17"
|
|||||||
asgi-lifespan = "^2.1.0"
|
asgi-lifespan = "^2.1.0"
|
||||||
pytest-md = "^0.2.0"
|
pytest-md = "^0.2.0"
|
||||||
pytest-httpserver = "^1.0.10"
|
pytest-httpserver = "^1.0.10"
|
||||||
|
pytest-mock = "^3.14.0"
|
||||||
|
types-mock = "^5.1.0.20240311"
|
||||||
|
mock = "^5.1.0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
119
tests/helpers.py
119
tests/helpers.py
@ -5,12 +5,11 @@ import random
|
|||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
from subprocess import PIPE, Popen, TimeoutExpired
|
from subprocess import PIPE, Popen, TimeoutExpired
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from psycopg2 import connect
|
from psycopg2 import connect
|
||||||
from psycopg2.errors import InvalidCatalogName
|
from psycopg2.errors import InvalidCatalogName
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from lnbits import core
|
from lnbits import core
|
||||||
from lnbits.db import DB_TYPE, POSTGRES, FromRowModel
|
from lnbits.db import DB_TYPE, POSTGRES, FromRowModel
|
||||||
@ -179,119 +178,3 @@ def clean_database(settings):
|
|||||||
# TODO: do this once mock data is removed from test data folder
|
# TODO: do this once mock data is removed from test data folder
|
||||||
# os.remove(settings.lnbits_data_folder + "/database.sqlite3")
|
# os.remove(settings.lnbits_data_folder + "/database.sqlite3")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def rest_wallet_fixtures_from_json(path) -> List["WalletTest"]:
|
|
||||||
with open(path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
funding_sources = data["funding_sources"]
|
|
||||||
|
|
||||||
tests: Dict[str, List[WalletTest]] = {
|
|
||||||
fs_name: [] for fs_name in funding_sources
|
|
||||||
}
|
|
||||||
|
|
||||||
for fn_name in data["functions"]:
|
|
||||||
fn = data["functions"][fn_name]
|
|
||||||
|
|
||||||
for test in fn["tests"]:
|
|
||||||
"""create an unit test for each funding source"""
|
|
||||||
|
|
||||||
for fs_name in funding_sources:
|
|
||||||
t = WalletTest(
|
|
||||||
**{
|
|
||||||
"funding_source": FundingSourceConfig(
|
|
||||||
**funding_sources[fs_name]
|
|
||||||
),
|
|
||||||
"function": fn_name,
|
|
||||||
**test,
|
|
||||||
"mocks": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if "mocks" in test:
|
|
||||||
if fs_name not in test["mocks"]:
|
|
||||||
t.skip = True
|
|
||||||
tests[fs_name].append(t)
|
|
||||||
continue
|
|
||||||
|
|
||||||
test_mocks_names = test["mocks"][fs_name]
|
|
||||||
|
|
||||||
fs_mocks = fn["mocks"][fs_name]
|
|
||||||
for mock_name in fs_mocks:
|
|
||||||
for test_mock in test_mocks_names[mock_name]:
|
|
||||||
# different mocks that result in the same
|
|
||||||
# return value for the tested function
|
|
||||||
_mock = fs_mocks[mock_name] | test_mock
|
|
||||||
mock = Mock(**_mock)
|
|
||||||
|
|
||||||
unique_test = WalletTest(**t.dict())
|
|
||||||
unique_test.description = (
|
|
||||||
f"""{t.description}:{mock.description or ""}"""
|
|
||||||
)
|
|
||||||
unique_test.mocks = t.mocks + [mock]
|
|
||||||
unique_test.skip = mock.skip
|
|
||||||
|
|
||||||
tests[fs_name].append(unique_test)
|
|
||||||
else:
|
|
||||||
# add the test without mocks
|
|
||||||
tests[fs_name].append(t)
|
|
||||||
|
|
||||||
all_tests = sum([tests[fs_name] for fs_name in tests], [])
|
|
||||||
return all_tests
|
|
||||||
|
|
||||||
|
|
||||||
class FundingSourceConfig(BaseModel):
|
|
||||||
wallet_class: str
|
|
||||||
settings: dict
|
|
||||||
|
|
||||||
|
|
||||||
class FunctionMock(BaseModel):
|
|
||||||
uri: str
|
|
||||||
query_params: Optional[dict]
|
|
||||||
headers: dict
|
|
||||||
method: 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):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
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] = []
|
|
||||||
|
BIN
tests/wallets/fixtures/certificates/breez.crt
Normal file
BIN
tests/wallets/fixtures/certificates/breez.crt
Normal file
Binary file not shown.
32
tests/wallets/fixtures/certificates/cert.pem
Normal file
32
tests/wallets/fixtures/certificates/cert.pem
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
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
|
||||||
|
SN2A
|
||||||
|
-----END CERTIFICATE-----
|
@ -4,7 +4,8 @@
|
|||||||
"wallet_class": "CoreLightningRestWallet",
|
"wallet_class": "CoreLightningRestWallet",
|
||||||
"settings": {
|
"settings": {
|
||||||
"corelightning_rest_url": "http://127.0.0.1:8555",
|
"corelightning_rest_url": "http://127.0.0.1:8555",
|
||||||
"corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn"
|
"corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn",
|
||||||
|
"user_agent": "LNbits/Tests"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lndrest": {
|
"lndrest": {
|
||||||
@ -12,14 +13,16 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"lnd_rest_endpoint": "http://127.0.0.1:8555",
|
"lnd_rest_endpoint": "http://127.0.0.1:8555",
|
||||||
"lnd_rest_macaroon": "eNcRyPtEdMaCaRoOn",
|
"lnd_rest_macaroon": "eNcRyPtEdMaCaRoOn",
|
||||||
"lnd_rest_cert": ""
|
"lnd_rest_cert": "",
|
||||||
|
"user_agent": "LNbits/Tests"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"alby": {
|
"alby": {
|
||||||
"wallet_class": "AlbyWallet",
|
"wallet_class": "AlbyWallet",
|
||||||
"settings": {
|
"settings": {
|
||||||
"alby_api_endpoint": "http://127.0.0.1:8555",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
1527
tests/wallets/fixtures/json/fixtures_rpc.json
Normal file
1527
tests/wallets/fixtures/json/fixtures_rpc.json
Normal file
File diff suppressed because it is too large
Load Diff
134
tests/wallets/fixtures/models.py
Normal file
134
tests/wallets/fixtures/models.py
Normal 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
117
tests/wallets/helpers.py
Normal 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]
|
@ -1,4 +1,3 @@
|
|||||||
import importlib
|
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Union
|
from typing import Dict, Union
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@ -7,16 +6,15 @@ import pytest
|
|||||||
from pytest_httpserver import HTTPServer
|
from pytest_httpserver import HTTPServer
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
from lnbits.core.models import BaseWallet
|
from tests.wallets.fixtures.models import Mock
|
||||||
from tests.helpers import (
|
from tests.wallets.helpers import (
|
||||||
FundingSourceConfig,
|
|
||||||
Mock,
|
|
||||||
WalletTest,
|
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:
|
# todo:
|
||||||
# - tests for extra fields
|
# - tests for extra fields
|
||||||
# - tests for paid_invoices_stream
|
# - tests for paid_invoices_stream
|
||||||
@ -29,14 +27,10 @@ def httpserver_listen_address():
|
|||||||
return ("127.0.0.1", 8555)
|
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.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"test_data",
|
"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,
|
ids=build_test_id,
|
||||||
)
|
)
|
||||||
async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest):
|
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:
|
for mock in test_data.mocks:
|
||||||
_apply_mock(httpserver, mock)
|
_apply_mock(httpserver, mock)
|
||||||
|
|
||||||
wallet = _load_funding_source(test_data.funding_source)
|
wallet = load_funding_source(test_data.funding_source)
|
||||||
await _check_assertions(wallet, test_data)
|
await check_assertions(wallet, test_data)
|
||||||
|
|
||||||
|
|
||||||
def _apply_mock(httpserver: HTTPServer, mock: Mock):
|
def _apply_mock(httpserver: HTTPServer, mock: Mock):
|
||||||
@ -65,6 +59,8 @@ def _apply_mock(httpserver: HTTPServer, mock: Mock):
|
|||||||
if mock.query_params:
|
if mock.query_params:
|
||||||
request_data["query_string"] = 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(
|
req = httpserver.expect_request(
|
||||||
uri=mock.uri,
|
uri=mock.uri,
|
||||||
headers=mock.headers,
|
headers=mock.headers,
|
||||||
@ -84,60 +80,3 @@ def _apply_mock(httpserver: HTTPServer, mock: Mock):
|
|||||||
respond_with = f"respond_with_{response_type}"
|
respond_with = f"respond_with_{response_type}"
|
||||||
|
|
||||||
getattr(req, respond_with)(server_response)
|
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
|
|
||||||
|
145
tests/wallets/test_rpc_wallets.py
Normal file
145
tests/wallets/test_rpc_wallets.py
Normal 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)
|
Loading…
x
Reference in New Issue
Block a user