From bfda0b62daa05283cb7064e1d2fea339808ce832 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Apr 2024 12:18:21 +0300 Subject: [PATCH] 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) --- .gitignore | 1 + lnbits/wallets/base.py | 28 + lnbits/wallets/corelightningrest.py | 149 ++-- lnbits/wallets/lndrest.py | 118 ++- poetry.lock | 55 +- pyproject.toml | 1 + tests/helpers.py | 110 ++- tests/wallets/fixtures.json | 1195 +++++++++++++++++++++++++++ tests/wallets/test_rest_wallets.py | 140 ++++ 9 files changed, 1705 insertions(+), 92 deletions(-) create mode 100644 tests/wallets/fixtures.json create mode 100644 tests/wallets/test_rest_wallets.py diff --git a/.gitignore b/.gitignore index b0e08c4a32..dd8cde71c1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__ *.egg *.egg-info .coverage +.coverage.* .pytest_cache .webassets-cache htmlcov diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 031cccc88d..3ca38e820e 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -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 diff --git a/lnbits/wallets/corelightningrest.py b/lnbits/wallets/corelightningrest.py index dda13b3545..870e51e6c3 100644 --- a/lnbits/wallets/corelightningrest.py +++ b/lnbits/wallets/corelightningrest.py @@ -66,23 +66,28 @@ async def cleanup(self): 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 @@ async def create_invoice( 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 @@ async def create_invoice( 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 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse 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( diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 9dd89c425e..31780b4daa 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -87,15 +87,20 @@ async def status(self) -> StatusResponse: 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 @@ async def create_invoice( 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") + + 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_request" not in data or "r_hash" not in data: + return InvoiceResponse( + False, None, None, "Server error: 'missing required fields'" + ) - data = r.json() - payment_request = data["payment_request"] - payment_hash = base64.b64decode(data["r_hash"]).hex() - checking_id = payment_hash + 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) + 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 @@ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse 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) + + 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'" + ) - 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) + 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: diff --git a/poetry.lock b/poetry.lock index 178c71b35a..f06dfebde2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 1d444ac260..ba9a8d8495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/helpers.py b/tests/helpers.py index 4b649075fc..e950be38bd 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,11 +5,12 @@ 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] = [] diff --git a/tests/wallets/fixtures.json b/tests/wallets/fixtures.json new file mode 100644 index 0000000000..bb9aea1492 --- /dev/null +++ b/tests/wallets/fixtures.json @@ -0,0 +1,1195 @@ +{ + "funding_sources": { + "corelightningrest": { + "wallet_class": "CoreLightningRestWallet", + "settings": { + "corelightning_rest_url": "http://127.0.0.1:8555", + "corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn" + } + }, + "lndrest": { + "wallet_class": "LndRestWallet", + "settings": { + "lnd_rest_endpoint": "http://127.0.0.1:8555", + "lnd_rest_macaroon": "eNcRyPtEdMaCaRoOn", + "lnd_rest_cert": "" + } + } + }, + "functions": { + "status": { + "mocks": { + "corelightningrest": { + "status_endpoint": { + "uri": "/v1/channel/localremotebal", + "headers": { + "macaroon": "eNcRyPtEdMaCaRoOn", + "encodingtype": "hex", + "accept": "application/json", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } + }, + "lndrest": { + "status_endpoint": { + "uri": "/v1/balance/channels", + "headers": { + "Grpc-Metadata-macaroon": "eNcRyPtEdMaCaRoOn", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } + } + }, + "tests": [ + { + "description": "success", + "call_params": {}, + "expect": { + "error_message": null, + "balance_msat": 55000 + }, + "mocks": { + "corelightningrest": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "localBalance": 55 + } + } + ] + }, + "lndrest": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "balance": 55 + } + } + ] + } + } + }, + { + "description": "error", + "call_params": {}, + "expect": { + "error_message": "Server error: '\"test-error\"'", + "balance_msat": 0 + }, + "mocks": { + "corelightningrest": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "error": "\"test-error\"" + } + } + ] + }, + "lndrest": { + "status_endpoint": [ + { + "response_type": "json", + "response": "test-error" + } + ] + } + } + }, + { + "description": "missing data", + "call_params": {}, + "expect": { + "error_message": "no data", + "balance_msat": 0 + }, + "mocks": { + "corelightningrest": { + "status_endpoint": [ + { + "response_type": "json", + "response": {} + } + ] + }, + "lndrest": { + "status_endpoint": [ + { + "response_type": "json", + "response": {} + } + ] + } + } + }, + { + "description": "bad json", + "call_params": {}, + "expect": { + "error_message": "Server error: 'invalid json response'", + "balance_msat": 0 + }, + "mocks": { + "corelightningrest": { + "status_endpoint": [ + { + "response_type": "data", + "response": "data-not-json" + } + ] + }, + "lndrest": { + "status_endpoint": [ + { + "response_type": "data", + "response": "data-not-json" + } + ] + } + } + }, + { + "description": "http 404", + "call_params": {}, + "expect": { + "error_message": "Unable to connect to http://127.0.0.1:8555.", + "balance_msat": 0 + }, + "mocks": { + "corelightningrest": { + "status_endpoint": [ + { + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + }, + "lndrest": { + "status_endpoint": [ + { + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + } + } + }, + { + "description": "no mocks", + "call_params": {}, + "expect": { + "error_message": "Unable to connect to http://127.0.0.1:8555.", + "balance_msat": 0 + } + } + ] + }, + "create_invoice": { + "mocks": { + "corelightningrest": { + "create_invoice_endpoint": { + "uri": "/v1/invoice/genInvoice", + "headers": { + "macaroon": "eNcRyPtEdMaCaRoOn", + "encodingtype": "hex", + "accept": "application/json", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } + }, + "lndrest": { + "create_invoice_endpoint": { + "uri": "/v1/invoices", + "headers": { + "Grpc-Metadata-macaroon": "eNcRyPtEdMaCaRoOn", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } + } + }, + "tests": [ + { + "description": "success", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": true, + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n", + "error_message": null + }, + "mocks": { + "corelightningrest": { + "create_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "amount": 555000, + "description": "Test Invoice", + "label": "test-label" + }, + "response_type": "json", + "response": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "bolt11": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n" + } + } + ] + }, + "lndrest": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice", + "private": true + }, + "response_type": "json", + "response": { + "r_hash": "41UmpD0E6YVZTA36uEiBT1JLHHhlmOyaY77dstcmrJY=", + "payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n" + } + } + ] + } + } + }, + { + "description": "error", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "Server error: 'Test Error'" + }, + "mocks": { + "corelightningrest": { + "create_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "amount": 555000, + "description": "Test Invoice", + "label": "test-label" + }, + "response_type": "json", + "response": { + "error": "Test Error" + } + } + ] + }, + "lndrest": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice", + "private": true + }, + "response_type": "json", + "response": { + "error": "Test Error" + } + } + ] + } + } + }, + { + "description": "missing data", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "Server error: 'missing required fields'" + }, + "mocks": { + "corelightningrest": { + "create_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "amount": 555000, + "description": "Test Invoice", + "label": "test-label" + }, + "response_type": "json", + "response": { + "some_field": "but the required fields are mising" + } + } + ] + }, + "lndrest": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice", + "private": true + }, + "response_type": "json", + "response": { + "some_field": "but the required fields are mising" + } + } + ] + } + } + }, + { + "description": "bad json", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "Server error: 'invalid json response'" + }, + "mocks": { + "corelightningrest": { + "create_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "amount": 555000, + "description": "Test Invoice", + "label": "test-label" + }, + "response_type": "data", + "response": "data-not-json" + } + ] + }, + "lndrest": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice", + "private": true + }, + "response_type": "data", + "response": "data-not-json" + } + ] + } + } + }, + { + "description": "http 404", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "Unable to connect to http://127.0.0.1:8555." + }, + "mocks": { + "corelightningrest": { + "create_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "amount": 555000, + "description": "Test Invoice", + "label": "test-label" + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + }, + "lndrest": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice", + "private": true + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + } + } + }, + { + "description": "no mocks", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "Unable to connect to http://127.0.0.1:8555." + } + } + ] + }, + "pay_invoice": { + "mocks": { + "corelightningrest": { + "pay_invoice_endpoint": { + "uri": "/v1/pay", + "headers": { + "macaroon": "eNcRyPtEdMaCaRoOn", + "encodingtype": "hex", + "accept": "application/json", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } + }, + "lndrest": { + "pay_invoice_endpoint": { + "uri": "/v1/channels/transactions", + "headers": { + "Grpc-Metadata-macaroon": "eNcRyPtEdMaCaRoOn", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } + } + }, + "tests": [ + { + "description": "success", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": true, + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "fee_msat": 50, + "preimage": "0000000000000000000000000000000000000000000000000000000000000000", + "error_message": null + }, + "mocks": { + "corelightningrest": { + "pay_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "maxfeepercent": "119.04761905", + "exemptfee": 0 + }, + "response_type": "json", + "response": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000", + "msatoshi": 21000, + "msatoshi_sent": 21050, + "status": "paid" + } + } + ] + }, + "lndrest": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit": 25000 + }, + "response_type": "json", + "response": { + "payment_hash": "41UmpD0E6YVZTA36uEiBT1JLHHhlmOyaY77dstcmrJY=", + "payment_route": { + "total_fees_msat": 50 + }, + "payment_preimage": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + } + ] + } + } + }, + { + "description": "error", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "Test Error" + }, + "mocks": { + "corelightningrest": { + "pay_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "maxfeepercent": "119.04761905", + "exemptfee": 0 + }, + "response_type": "json", + "response": { + "error": "Test Error" + } + } + ] + }, + "lndrest": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit": 25000 + }, + "response_type": "json", + "response": { + "payment_error": "Test Error" + } + } + ] + } + } + }, + { + "description": "missing data", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "Server error: 'missing required fields'" + }, + "mocks": { + "corelightningrest": { + "pay_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "maxfeepercent": "119.04761905", + "exemptfee": 0 + }, + "response_type": "json", + "response": {} + } + ] + }, + "lndrest": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit": 25000 + }, + "response_type": "json", + "response": {} + } + ] + } + } + }, + { + "description": "bad json", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "Server error: 'invalid json response'" + }, + "mocks": { + "corelightningrest": { + "pay_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "maxfeepercent": "119.04761905", + "exemptfee": 0 + }, + "response_type": "data", + "response": "data-not-json" + } + ] + }, + "lndrest": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit": 25000 + }, + "response_type": "data", + "response": "data-not-json" + } + ] + } + } + }, + { + "description": "http 404", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "Unable to connect to http://127.0.0.1:8555." + }, + "mocks": { + "corelightningrest": { + "pay_invoice_endpoint": [ + { + "request_type": "data", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "maxfeepercent": "119.04761905", + "exemptfee": 0 + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + }, + "lndrest": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit": 25000 + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + } + } + }, + { + "description": "no mocks", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "Unable to connect to http://127.0.0.1:8555." + } + } + ] + }, + "get_invoice_status": { + "mocks": { + "corelightningrest": { + "get_invoice_status_endpoint": { + "uri": "/v1/invoice/listInvoices", + "query_params": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "headers": { + "macaroon": "eNcRyPtEdMaCaRoOn", + "encodingtype": "hex", + "accept": "application/json", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } + }, + "lndrest": { + "get_invoice_status_endpoint": { + "uri": "/v1/invoice/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "headers": { + "Grpc-Metadata-macaroon": "eNcRyPtEdMaCaRoOn", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } + } + }, + "tests": [ + { + "description": "paid", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": true, + "failed": false, + "pending": false + }, + "mocks": { + "corelightningrest": { + "get_invoice_status_endpoint": [ + { + "response_type": "json", + "response": { + "invoices": [ + { + "status": "paid" + } + ] + } + }, + { + "response_type": "json", + "response": { + "invoices": [ + { + "status": "complete" + } + ] + } + } + ] + }, + "lndrest": { + "get_invoice_status_endpoint": [ + { + "response_type": "json", + "response": { + "settled": true + } + } + ] + } + } + }, + { + "description": "failed", + "description1": "pending should be false in the 'expect', this is a bug", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": false, + "failed": true, + "pending": true + }, + "mocks": { + "corelightningrest": { + "get_invoice_status_endpoint": [ + { + "response_type": "json", + "response": { + "invoices": [ + { + "status": "failed" + } + ] + } + } + ] + }, + "lndrest": { + "description": "lndrest.py doesn't handle the 'failed' status for `get_invoice_status`", + "get_invoice_status_endpoint": [] + } + } + }, + { + "description": "pending", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": false, + "failed": false, + "pending": true + }, + "mocks": { + "corelightningrest": { + "get_invoice_status_endpoint": [ + { + "description": "no data", + "response_type": "json", + "response": {} + }, + { + "description": "error status", + "response_type": "json", + "response": { + "error": "test-error" + } + }, + { + "description": "bad json", + "response_type": "data", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + }, + "lndrest": { + "get_invoice_status_endpoint": [ + { + "description": "error status", + "response_type": "json", + "response": {} + }, + { + "description": "error status", + "response_type": "json", + "response": { + "seetled": false + } + }, + { + "description": "bad json", + "response_type": "data", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + } + } + }, + { + "description": "no mocks", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": false, + "failed": false, + "pending": true + } + } + ] + }, + "get_payment_status": { + "mocks": { + "corelightningrest": { + "get_payment_status_endpoint": { + "uri": "/v1/pay/listPays", + "query_params": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "headers": { + "macaroon": "eNcRyPtEdMaCaRoOn", + "encodingtype": "hex", + "accept": "application/json", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } + }, + "lndrest": { + "get_payment_status_endpoint": { + "uri": "/v2/router/track/41UmpD0E6YVZTA36uEiBT1JLHHhlmOyaY77dstcmrJY=", + "headers": { + "Grpc-Metadata-macaroon": "eNcRyPtEdMaCaRoOn", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } + } + }, + "tests": [ + { + "description": "paid", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "preimage": "0000000000000000000000000000000000000000000000000000000000000000", + "success": true, + "failed": false, + "pending": false + }, + "mocks": { + "corelightningrest": { + "get_payment_status_endpoint": [ + { + "response_type": "json", + "response": { + "pays": [ + { + "status": "complete", + "amount_msat": "21000msat", + "amount_sent_msat": "-22000msat", + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + } + ] + }, + "lndrest": { + "get_payment_status_endpoint": [ + { + "response_type": "stream", + "response": { + "result": { + "status": "SUCCEEDED", + "fee_msat": 1000, + "payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + } + ] + } + } + }, + { + "description": "failed", + "description1": "pending should be false in the 'expect', this is a bug", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "preimage": null, + "success": false, + "failed": true, + "pending": true + }, + "mocks": { + "corelightningrest": { + "get_payment_status_endpoint": [ + { + "response_type": "json", + "response": { + "pays": [ + { + "status": "failed" + } + ] + } + } + ] + }, + "lndrest": { + "get_payment_status_endpoint": [ + { + "response_type": "stream", + "response": { + "result": { + "status": "FAILED" + } + } + }, + { + "description": "error code 5", + "response_type": "stream", + "response": { + "error": { + "code": 5, + "message": "payment isn't initiated" + } + } + } + ] + } + } + }, + { + "description": "pending", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "preimage": null, + "success": false, + "failed": false, + "pending": true + }, + "mocks": { + "corelightningrest": { + "get_payment_status_endpoint": [ + { + "description": "pending status", + "response_type": "json", + "response": { + "pays": [ + { + "status": "pending" + } + ] + } + }, + { + "description": "no data", + "response_type": "json", + "response": {} + }, + { + "description": "error status", + "response_type": "json", + "response": { + "error": "test-error" + } + }, + { + "description": "bad json", + "response_type": "data", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + }, + "lndrest": { + "get_payment_status_endpoint": [ + { + "description": "UNKNOWN", + "response_type": "stream", + "response": { + "result": { + "status": "UNKNOWN" + } + } + }, + { + "description": "IN_FLIGHT", + "response_type": "stream", + "response": { + "result": { + "status": "IN_FLIGHT" + } + } + }, + { + "description": "error code 4", + "response_type": "stream", + "response": { + "error": { + "code": 5, + "message": "should not fail" + } + } + }, + { + "description": "no data", + "response_type": "stream", + "response": {} + }, + { + "description": "bad json", + "response_type": "stream", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] + } + } + }, + { + "description": "no mocks", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "preimage": null, + "success": false, + "failed": false, + "pending": true + } + } + ] + } + } +} diff --git a/tests/wallets/test_rest_wallets.py b/tests/wallets/test_rest_wallets.py new file mode 100644 index 0000000000..e10f7409ca --- /dev/null +++ b/tests/wallets/test_rest_wallets.py @@ -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