mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-03-15 12:20:21 +01:00
test: add unit tests for wallets (funding sources) (#2363)
* test: initial commit * feat: allow external label for `create_invoice` (useful for testing) * chore: code format * fix: ignore temp coverage files * feat: add properties to the Status classes for a better readability * fix: add extra validation for data * fix: comment out bad `status.pending` (to be fixed in core) * fix: 404 tests * test: first draft of generic rest wallet tests * test: migrate two more tests * feat: add response type * feat: test exceptions * test: extract first `create_invoice` test * chore: reminder * add: error test * chore: code format * chore: experiment * feat: adapt parsing * refactor: data structure * fix: some tests * refactor: extract methods * fix: make response uniform * fix: test data * chore: clean-up * fix: uniform responses * fix: user agent * fix: user agent * fix: user-agent again * test: add `with error` test * feat: customize test name * fix: better exception handling for `status` * fix: add `try-catch` for `raise_for_status` * test: with no mocks * chore: clean-up generalized tests * chore: code format * chore: code format * chore: remove extracted tests * test: add `create_invoice`: error test * add: test for `create_invoice` with http 404 * test: extract `test_pay_invoice_ok` * test: extract `test_pay_invoice_error_response` * test: extract `test_pay_invoice_http_404` * test: add "missing data" * test: add `bad-json` * test: add `no mocks` for `create_invoice` * test: add `no mocks` for `pay_invoice` * test: add `bad json` tests * chore: re-order tests * fix: response type * test: add `missing data` test for `pay_imvoice` * chore: re-order tests * test: add `success` test for `get_invoice_status ` * feat: update test structure * test: new status * test: add more test * fix: error handling * chore: code clean-up * test: add success test for `get_payment_status ` * test: add `pending` tests for `check_payment_status` * chore: remove extracted tests * test: add more tests * test: add `no mocks` test * fix: funding source loading * refactor: extract `rest_wallet_fixtures_from_json` function * chore: update comment * feat: cover `cleanup` call also * chore: code format * refactor: start to extract data model * refactor: extract mock class * fix: typings * refactor: improve typings * chore: add some documentation * chore: final clean-up * chore: rename file * chore: `poetry add --dev pytest_httpserver` (after rebase)
This commit is contained in:
parent
b0a8e0d942
commit
bfda0b62da
9 changed files with 1705 additions and 92 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -11,6 +11,7 @@ __pycache__
|
|||
*.egg
|
||||
*.egg-info
|
||||
.coverage
|
||||
.coverage.*
|
||||
.pytest_cache
|
||||
.webassets-cache
|
||||
htmlcov
|
||||
|
|
|
@ -18,6 +18,18 @@ class InvoiceResponse(NamedTuple):
|
|||
payment_request: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.ok is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.ok is None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.ok is False
|
||||
|
||||
|
||||
class PaymentResponse(NamedTuple):
|
||||
# when ok is None it means we don't know if this succeeded
|
||||
|
@ -27,12 +39,28 @@ class PaymentResponse(NamedTuple):
|
|||
preimage: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.ok is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.ok is None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.ok is False
|
||||
|
||||
|
||||
class PaymentStatus(NamedTuple):
|
||||
paid: Optional[bool] = None
|
||||
fee_msat: Optional[int] = None
|
||||
preimage: Optional[str] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.paid is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.paid is not True
|
||||
|
|
|
@ -66,23 +66,28 @@ class CoreLightningRestWallet(Wallet):
|
|||
logger.warning(f"Error closing wallet connection: {e}")
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
r = await self.client.get(f"{self.url}/v1/channel/localremotebal", timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_error or "error" in r.json():
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
return StatusResponse(
|
||||
f"Failed to connect to {self.url}, got: '{error_message}...'", 0
|
||||
try:
|
||||
r = await self.client.get(
|
||||
f"{self.url}/v1/channel/localremotebal", timeout=5
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
data = r.json()
|
||||
if len(data) == 0:
|
||||
return StatusResponse("no data", 0)
|
||||
if len(data) == 0:
|
||||
return StatusResponse("no data", 0)
|
||||
|
||||
return StatusResponse(None, int(data.get("localBalance") * 1000))
|
||||
if "error" in data:
|
||||
return StatusResponse(f"""Server error: '{data["error"]}'""", 0)
|
||||
|
||||
if r.is_error or "localBalance" not in data:
|
||||
return StatusResponse(f"Server error: '{r.text}'", 0)
|
||||
|
||||
return StatusResponse(None, int(data.get("localBalance") * 1000))
|
||||
except json.JSONDecodeError:
|
||||
return StatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Unable to connect to {self.url}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
|
@ -92,7 +97,7 @@ class CoreLightningRestWallet(Wallet):
|
|||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = f"lbl{random.random()}"
|
||||
label = kwargs.get("label", f"lbl{random.random()}")
|
||||
data: Dict = {
|
||||
"amount": amount * 1000,
|
||||
"description": memo,
|
||||
|
@ -113,24 +118,41 @@ class CoreLightningRestWallet(Wallet):
|
|||
if kwargs.get("preimage"):
|
||||
data["preimage"] = kwargs["preimage"]
|
||||
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/invoice/genInvoice",
|
||||
data=data,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/invoice/genInvoice",
|
||||
data=data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
if r.is_error or "error" in r.json():
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
data = r.json()
|
||||
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(False, None, None, "no data")
|
||||
|
||||
data = r.json()
|
||||
assert "payment_hash" in data
|
||||
assert "bolt11" in data
|
||||
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
|
||||
|
||||
if "payment_hash" not in data or "bolt11" not in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
|
@ -142,34 +164,53 @@ class CoreLightningRestWallet(Wallet):
|
|||
error_message = "0 amount invoices are not allowed"
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/pay",
|
||||
data={
|
||||
"invoice": 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)
|
||||
},
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/pay",
|
||||
data={
|
||||
"invoice": 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)
|
||||
},
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
if r.is_error or "error" in r.json():
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
data = r.json()
|
||||
if "error" in data:
|
||||
return PaymentResponse(False, None, None, None, data["error"])
|
||||
if r.is_error:
|
||||
return PaymentResponse(False, None, None, None, r.text)
|
||||
if (
|
||||
"payment_hash" not in data
|
||||
or "payment_preimage" not in data
|
||||
or "msatoshi_sent" not in data
|
||||
or "msatoshi" not in data
|
||||
or "status" not in data
|
||||
):
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
checking_id = data["payment_hash"]
|
||||
preimage = data["payment_preimage"]
|
||||
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
|
||||
checking_id = data["payment_hash"]
|
||||
preimage = data["payment_preimage"]
|
||||
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
|
||||
|
||||
return PaymentResponse(
|
||||
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
|
||||
)
|
||||
return PaymentResponse(
|
||||
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
False, None, None, None, f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(
|
||||
|
|
|
@ -87,15 +87,20 @@ class LndRestWallet(Wallet):
|
|||
try:
|
||||
r = await self.client.get("/v1/balance/channels")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError) as exc:
|
||||
return StatusResponse(f"Unable to connect to {self.endpoint}. {exc}", 0)
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
if r.is_error:
|
||||
raise Exception
|
||||
except Exception:
|
||||
return StatusResponse(r.text[:200], 0)
|
||||
|
||||
if len(data) == 0:
|
||||
return StatusResponse("no data", 0)
|
||||
|
||||
if r.is_error or "balance" not in data:
|
||||
return StatusResponse(f"Server error: '{r.text}'", 0)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return StatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
return StatusResponse(None, int(data["balance"]) * 1000)
|
||||
|
||||
|
@ -123,22 +128,41 @@ class LndRestWallet(Wallet):
|
|||
hashlib.sha256(unhashed_description).digest()
|
||||
).decode("ascii")
|
||||
|
||||
r = await self.client.post(url="/v1/invoices", json=data)
|
||||
try:
|
||||
r = await self.client.post(url="/v1/invoices", json=data)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.text
|
||||
try:
|
||||
error_message = r.json()["error"]
|
||||
except Exception:
|
||||
pass
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(False, None, None, "no data")
|
||||
|
||||
data = r.json()
|
||||
payment_request = data["payment_request"]
|
||||
payment_hash = base64.b64decode(data["r_hash"]).hex()
|
||||
checking_id = payment_hash
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
if r.is_error:
|
||||
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
|
||||
|
||||
if "payment_request" not in data or "r_hash" not in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
payment_request = data["payment_request"]
|
||||
payment_hash = base64.b64decode(data["r_hash"]).hex()
|
||||
checking_id = payment_hash
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# set the fee limit for the payment
|
||||
|
@ -154,29 +178,53 @@ class LndRestWallet(Wallet):
|
|||
r.raise_for_status()
|
||||
except Exception as exc:
|
||||
logger.warning(f"LndRestWallet pay_invoice POST error: {exc}.")
|
||||
return PaymentResponse(None, None, None, None, str(exc))
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
data = r.json()
|
||||
try:
|
||||
data = r.json()
|
||||
|
||||
if data.get("payment_error"):
|
||||
error_message = r.json().get("payment_error") or r.text
|
||||
logger.warning(f"LndRestWallet pay_invoice payment_error: {error_message}.")
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
if data.get("payment_error"):
|
||||
error_message = r.json().get("payment_error") or r.text
|
||||
logger.warning(
|
||||
f"LndRestWallet pay_invoice payment_error: {error_message}."
|
||||
)
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
|
||||
data = r.json()
|
||||
checking_id = base64.b64decode(data["payment_hash"]).hex()
|
||||
fee_msat = int(data["payment_route"]["total_fees_msat"])
|
||||
preimage = base64.b64decode(data["payment_preimage"]).hex()
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
if (
|
||||
"payment_hash" not in data
|
||||
or "payment_route" not in data
|
||||
or "total_fees_msat" not in data["payment_route"]
|
||||
or "payment_preimage" not in data
|
||||
):
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
checking_id = base64.b64decode(data["payment_hash"]).hex()
|
||||
fee_msat = int(data["payment_route"]["total_fees_msat"])
|
||||
preimage = base64.b64decode(data["payment_preimage"]).hex()
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(url=f"/v1/invoice/{checking_id}")
|
||||
|
||||
if r.is_error or not r.json().get("settled"):
|
||||
# this must also work when checking_id is not a hex recognizable by lnd
|
||||
# it will return an error and no "settled" attribute on the object
|
||||
return PaymentPendingStatus()
|
||||
try:
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if r.is_error or not data.get("settled"):
|
||||
# this must also work when checking_id is not a hex recognizable by lnd
|
||||
# it will return an error and no "settled" attribute on the object
|
||||
return PaymentPendingStatus()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
return PaymentPendingStatus()
|
||||
return PaymentSuccessStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
|
55
poetry.lock
generated
55
poetry.lock
generated
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
|
@ -1302,6 +1302,16 @@ files = [
|
|||
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
|
||||
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
|
||||
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
|
||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
|
||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
|
||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
|
||||
|
@ -1958,6 +1968,20 @@ pytest = ">=4.6"
|
|||
[package.extras]
|
||||
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-httpserver"
|
||||
version = "1.0.10"
|
||||
description = "pytest-httpserver is a httpserver for pytest"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest_httpserver-1.0.10-py3-none-any.whl", hash = "sha256:d40e0cc3d61ed6e4d80f52a796926d557a7db62b17e43b3e258a78a3c34becb9"},
|
||||
{file = "pytest_httpserver-1.0.10.tar.gz", hash = "sha256:77b9fbc2eb0a129cfbbacc8fe57e8cafe071d506489f31fe31e62f1b332d9905"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Werkzeug = ">=2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-md"
|
||||
version = "0.2.0"
|
||||
|
@ -2068,6 +2092,7 @@ files = [
|
|||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
||||
|
@ -2075,8 +2100,15 @@ files = [
|
|||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
||||
|
@ -2093,6 +2125,7 @@ files = [
|
|||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
||||
|
@ -2100,6 +2133,7 @@ files = [
|
|||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
||||
|
@ -2848,6 +2882,23 @@ files = [
|
|||
{file = "websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.0.2"
|
||||
description = "The comprehensive WSGI web application library."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "werkzeug-3.0.2-py3-none-any.whl", hash = "sha256:3aac3f5da756f93030740bc235d3e09449efcf65f2f55e3602e1d851b8f48795"},
|
||||
{file = "werkzeug-3.0.2.tar.gz", hash = "sha256:e39b645a6ac92822588e7b39a692e7828724ceae0b0d702ef96701f90e70128d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
MarkupSafe = ">=2.1.1"
|
||||
|
||||
[package.extras]
|
||||
watchdog = ["watchdog (>=2.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.1.0"
|
||||
|
@ -2962,4 +3013,4 @@ liquid = ["wallycore"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10 | ^3.9"
|
||||
content-hash = "a58655feabd699c4f4dd8ad67989f09dbc1385e6e62c46364bb3a7df4f254e8c"
|
||||
content-hash = "4c11cc117beb703ebece5fac43adbabae76804f084c39ef90a67edcfb56795d7"
|
||||
|
|
|
@ -73,6 +73,7 @@ openai = "^1.12.0"
|
|||
json5 = "^0.9.17"
|
||||
asgi-lifespan = "^2.1.0"
|
||||
pytest-md = "^0.2.0"
|
||||
pytest-httpserver = "^1.0.10"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
|
|
110
tests/helpers.py
110
tests/helpers.py
|
@ -5,11 +5,12 @@ import random
|
|||
import string
|
||||
import time
|
||||
from subprocess import PIPE, Popen, TimeoutExpired
|
||||
from typing import Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from loguru import logger
|
||||
from psycopg2 import connect
|
||||
from psycopg2.errors import InvalidCatalogName
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits import core
|
||||
from lnbits.db import DB_TYPE, POSTGRES, FromRowModel
|
||||
|
@ -178,3 +179,110 @@ def clean_database(settings):
|
|||
# TODO: do this once mock data is removed from test data folder
|
||||
# os.remove(settings.lnbits_data_folder + "/database.sqlite3")
|
||||
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:
|
||||
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]
|
||||
|
||||
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):
|
||||
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):
|
||||
function: str
|
||||
description: str
|
||||
funding_source: FundingSourceConfig
|
||||
call_params: Optional[dict] = {}
|
||||
expect: Optional[dict]
|
||||
expect_error: Optional[dict]
|
||||
mocks: List[Mock] = []
|
||||
|
|
1195
tests/wallets/fixtures.json
Normal file
1195
tests/wallets/fixtures.json
Normal file
File diff suppressed because it is too large
Load diff
140
tests/wallets/test_rest_wallets.py
Normal file
140
tests/wallets/test_rest_wallets.py
Normal file
|
@ -0,0 +1,140 @@
|
|||
import importlib
|
||||
import json
|
||||
from typing import Dict, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from pytest_httpserver import HTTPServer
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from lnbits.core.models import BaseWallet
|
||||
from tests.helpers import (
|
||||
FundingSourceConfig,
|
||||
Mock,
|
||||
WalletTest,
|
||||
rest_wallet_fixtures_from_json,
|
||||
)
|
||||
|
||||
wallets_module = importlib.import_module("lnbits.wallets")
|
||||
|
||||
# todo:
|
||||
# - tests for extra fields
|
||||
# - tests for paid_invoices_stream
|
||||
# - test particular validations
|
||||
|
||||
|
||||
# specify where the server should bind to
|
||||
@pytest.fixture(scope="session")
|
||||
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"),
|
||||
ids=build_test_id,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
def _apply_mock(httpserver: HTTPServer, mock: Mock):
|
||||
|
||||
request_data: Dict[str, Union[str, dict]] = {}
|
||||
request_type = getattr(mock.dict(), "request_type", None)
|
||||
# request_type = mock.request_type <--- this des not work for whatever reason!!!
|
||||
|
||||
if request_type == "data":
|
||||
assert isinstance(mock.response, dict), "request data must be JSON"
|
||||
request_data["data"] = urlencode(mock.response)
|
||||
elif request_type == "json":
|
||||
request_data["json"] = mock.response
|
||||
|
||||
if mock.query_params:
|
||||
request_data["query_string"] = mock.query_params
|
||||
|
||||
req = httpserver.expect_request(
|
||||
uri=mock.uri,
|
||||
headers=mock.headers,
|
||||
method=mock.method,
|
||||
**request_data, # type: ignore
|
||||
)
|
||||
|
||||
server_response: Union[str, dict, Response] = mock.response
|
||||
response_type = mock.response_type
|
||||
if response_type == "response":
|
||||
assert isinstance(server_response, dict), "server response must be JSON"
|
||||
server_response = Response(**server_response)
|
||||
elif response_type == "stream":
|
||||
response_type = "response"
|
||||
server_response = Response(iter(json.dumps(server_response).splitlines()))
|
||||
|
||||
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
|
Loading…
Add table
Reference in a new issue