diff --git a/flake.nix b/flake.nix index a6a0c45f2..167f187de 100644 --- a/flake.nix +++ b/flake.nix @@ -40,6 +40,9 @@ pytest-md = prev.pytest-md.overridePythonAttrs ( old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; } ); + types-mock = prev.pytest-md.overridePythonAttrs ( + old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; } + ); }); }; }); diff --git a/lnbits/wallets/corelightning.py b/lnbits/wallets/corelightning.py index 527b10abd..1b22768e7 100644 --- a/lnbits/wallets/corelightning.py +++ b/lnbits/wallets/corelightning.py @@ -54,12 +54,19 @@ class CoreLightningWallet(Wallet): async def status(self) -> StatusResponse: try: funds: dict = self.ln.listfunds() # type: ignore + if len(funds) == 0: + return StatusResponse("no data", 0) + return StatusResponse( None, sum([int(ch["our_amount_msat"]) for ch in funds["channels"]]) ) except RpcError as exc: - error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." + logger.warning(exc) + error_message = f"RPC '{exc.method}' failed with '{exc.error}'." return StatusResponse(error_message, 0) + except Exception as exc: + logger.warning(f"Failed to connect, got: '{exc}'") + return StatusResponse(f"Unable to connect, got: '{exc}'", 0) async def create_invoice( self, @@ -69,7 +76,7 @@ class CoreLightningWallet(Wallet): unhashed_description: Optional[bytes] = None, **kwargs, ) -> InvoiceResponse: - label = f"lbl{random.random()}" + label = kwargs.get("label", f"lbl{random.random()}") msat: int = int(amount * 1000) try: if description_hash and not unhashed_description: @@ -95,14 +102,18 @@ class CoreLightningWallet(Wallet): if r.get("code") and r.get("code") < 0: # type: ignore raise Exception(r.get("message")) - return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "") + return InvoiceResponse(True, r["payment_hash"], r["bolt11"], None) except RpcError as exc: - error_message = ( - f"CoreLightning method '{exc.method}' failed with" - f" '{exc.error.get('message') or exc.error}'." # type: ignore - ) + logger.warning(exc) + error_message = f"RPC '{exc.method}' failed with '{exc.error}'." return InvoiceResponse(False, None, None, error_message) + except KeyError as exc: + logger.warning(exc) + return InvoiceResponse( + False, None, None, "Server error: 'missing required fields'" + ) except Exception as e: + logger.warning(e) return InvoiceResponse(False, None, None, str(e)) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: @@ -111,94 +122,111 @@ class CoreLightningWallet(Wallet): except Bolt11Exception as exc: return PaymentResponse(False, None, None, None, str(exc)) - previous_payment = await self.get_payment_status(invoice.payment_hash) - if previous_payment.paid: - return PaymentResponse(False, None, None, None, "invoice already paid") - - if not invoice.amount_msat or invoice.amount_msat <= 0: - return PaymentResponse( - False, None, None, None, "CLN 0 amount invoice not supported" - ) - - fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 - # so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi - # (which is default value of exemptfee) - payload = { - "bolt11": bolt11, - "maxfeepercent": f"{fee_limit_percent:.11}", - "exemptfee": 0, - # so fee_limit_percent is applied even on payments with fee < 5000 - # millisatoshi (which is default value of exemptfee) - "description": invoice.description, - } try: + previous_payment = await self.get_payment_status(invoice.payment_hash) + if previous_payment.paid: + return PaymentResponse(False, None, None, None, "invoice already paid") + + if not invoice.amount_msat or invoice.amount_msat <= 0: + return PaymentResponse( + False, None, None, None, "CLN 0 amount invoice not supported" + ) + + fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 + # so fee_limit_percent is applied even + # on payments with fee < 5000 millisatoshi + # (which is default value of exemptfee) + payload = { + "bolt11": bolt11, + "maxfeepercent": f"{fee_limit_percent:.11}", + "exemptfee": 0, + # so fee_limit_percent is applied even on payments with fee < 5000 + # millisatoshi (which is default value of exemptfee) + "description": invoice.description, + } + r = await run_sync(lambda: self.ln.call("pay", payload)) + + fee_msat = -int(r["amount_sent_msat"] - r["amount_msat"]) + return PaymentResponse( + True, r["payment_hash"], fee_msat, r["payment_preimage"], None + ) except RpcError as exc: + logger.warning(exc) try: error_message = exc.error["attempts"][-1]["fail_reason"] # type: ignore except Exception: - error_message = ( - f"CoreLightning method '{exc.method}' failed with" - f" '{exc.error.get('message') or exc.error}'." # type: ignore - ) + error_message = f"RPC '{exc.method}' failed with '{exc.error}'." return PaymentResponse(False, None, None, None, error_message) - - fee_msat = -int(r["amount_sent_msat"] - r["amount_msat"]) - return PaymentResponse( - True, r["payment_hash"], fee_msat, r["payment_preimage"], None - ) + except KeyError as exc: + logger.warning(exc) + return PaymentResponse( + False, None, None, None, "Server error: 'missing required fields'" + ) + except Exception as exc: + logger.info(f"Failed to pay invoice {bolt11}") + logger.warning(exc) + return PaymentResponse(False, None, None, None, f"Payment failed: '{exc}'.") async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore - except RpcError: - return PaymentPendingStatus() - if not r["invoices"]: - return PaymentPendingStatus() - invoice_resp = r["invoices"][-1] - - if invoice_resp["payment_hash"] == checking_id: - if invoice_resp["status"] == "paid": - return PaymentSuccessStatus() - elif invoice_resp["status"] == "unpaid": + if not r["invoices"]: return PaymentPendingStatus() - elif invoice_resp["status"] == "expired": - return PaymentFailedStatus() - else: - logger.warning(f"supplied an invalid checking_id: {checking_id}") - return PaymentPendingStatus() + + invoice_resp = r["invoices"][-1] + + if invoice_resp["payment_hash"] == checking_id: + if invoice_resp["status"] == "paid": + return PaymentSuccessStatus() + elif invoice_resp["status"] == "unpaid": + return PaymentPendingStatus() + elif invoice_resp["status"] == "expired": + return PaymentFailedStatus() + else: + logger.warning(f"supplied an invalid checking_id: {checking_id}") + return PaymentPendingStatus() + except RpcError as exc: + logger.warning(exc) + return PaymentPendingStatus() + except Exception as exc: + logger.warning(exc) + return PaymentPendingStatus() async def get_payment_status(self, checking_id: str) -> PaymentStatus: try: r: dict = self.ln.listpays(payment_hash=checking_id) # type: ignore - except Exception: - return PaymentPendingStatus() - if "pays" not in r: - return PaymentPendingStatus() - if not r["pays"]: - # no payment with this payment_hash is found - return PaymentFailedStatus() - payment_resp = r["pays"][-1] - - if payment_resp["payment_hash"] == checking_id: - status = payment_resp["status"] - if status == "complete": - fee_msat = -int( - payment_resp["amount_sent_msat"] - payment_resp["amount_msat"] - ) - - return PaymentSuccessStatus( - fee_msat=fee_msat, preimage=payment_resp["preimage"] - ) - elif status == "failed": - return PaymentFailedStatus() - else: + if "pays" not in r: return PaymentPendingStatus() - else: - logger.warning(f"supplied an invalid checking_id: {checking_id}") - return PaymentPendingStatus() + if not r["pays"]: + # no payment with this payment_hash is found + return PaymentFailedStatus() + + payment_resp = r["pays"][-1] + + if payment_resp["payment_hash"] == checking_id: + status = payment_resp["status"] + if status == "complete": + fee_msat = -int( + payment_resp["amount_sent_msat"] - payment_resp["amount_msat"] + ) + + return PaymentSuccessStatus( + fee_msat=fee_msat, preimage=payment_resp["preimage"] + ) + elif status == "failed": + return PaymentFailedStatus() + else: + return PaymentPendingStatus() + else: + logger.warning(f"supplied an invalid checking_id: {checking_id}") + return PaymentPendingStatus() + + except Exception as exc: + logger.warning(exc) + return PaymentPendingStatus() async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: diff --git a/poetry.lock b/poetry.lock index f06dfebde..2862c08ef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1364,6 +1364,22 @@ docs = ["alabaster (==0.7.13)", "autodocsumm (==0.2.11)", "sphinx (==7.0.1)", "s lint = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)"] tests = ["pytest", "pytz", "simplejson"] +[[package]] +name = "mock" +version = "5.1.0" +description = "Rolling backport of unittest.mock for all Pythons" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, + {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, +] + +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + [[package]] name = "mypy" version = "1.7.1" @@ -1996,6 +2012,23 @@ files = [ [package.dependencies] pytest = ">=4.2.1" +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-crontab" version = "3.0.0" @@ -2592,6 +2625,17 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "types-mock" +version = "5.1.0.20240311" +description = "Typing stubs for mock" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-mock-5.1.0.20240311.tar.gz", hash = "sha256:7472797986d83016f96fde7f73577d129b0cd8a8d0b783487a7be330d57ba431"}, + {file = "types_mock-5.1.0.20240311-py3-none-any.whl", hash = "sha256:0769cb376dfc75b45215619f17a9fd6333d771cc29ce4a38937f060b1e45530f"}, +] + [[package]] name = "types-passlib" version = "1.7.7.13" @@ -3013,4 +3057,4 @@ liquid = ["wallycore"] [metadata] lock-version = "2.0" python-versions = "^3.10 | ^3.9" -content-hash = "4c11cc117beb703ebece5fac43adbabae76804f084c39ef90a67edcfb56795d7" +content-hash = "fd9ace1dada06a9a4556ffe888c9c391d1da4e2febd22084b6f53e6006eefa6e" diff --git a/pyproject.toml b/pyproject.toml index 891f185cd..26acc78bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,9 @@ json5 = "^0.9.17" asgi-lifespan = "^2.1.0" pytest-md = "^0.2.0" pytest-httpserver = "^1.0.10" +pytest-mock = "^3.14.0" +types-mock = "^5.1.0.20240311" +mock = "^5.1.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/helpers.py b/tests/helpers.py index 7286932af..7dcaffd76 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,12 +5,11 @@ import random import string import time from subprocess import PIPE, Popen, TimeoutExpired -from typing import Dict, List, Optional, Tuple, Union +from typing import Optional, Tuple 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 @@ -179,119 +178,3 @@ 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: - if fs_name not in test["mocks"]: - t.skip = True - tests[fs_name].append(t) - continue - - test_mocks_names = test["mocks"][fs_name] - - fs_mocks = fn["mocks"][fs_name] - for mock_name in fs_mocks: - for test_mock in test_mocks_names[mock_name]: - # different mocks that result in the same - # return value for the tested function - _mock = fs_mocks[mock_name] | test_mock - mock = Mock(**_mock) - - unique_test = WalletTest(**t.dict()) - unique_test.description = ( - f"""{t.description}:{mock.description or ""}""" - ) - unique_test.mocks = t.mocks + [mock] - unique_test.skip = mock.skip - - tests[fs_name].append(unique_test) - else: - # add the test without mocks - tests[fs_name].append(t) - - all_tests = sum([tests[fs_name] for fs_name in tests], []) - return all_tests - - -class FundingSourceConfig(BaseModel): - wallet_class: str - settings: dict - - -class FunctionMock(BaseModel): - uri: str - query_params: Optional[dict] - headers: dict - method: str - - -class TestMock(BaseModel): - skip: Optional[bool] - description: Optional[str] - request_type: Optional[str] - request_body: Optional[dict] - response_type: str - response: Union[str, dict] - - -class Mock(FunctionMock, TestMock): - pass - - -class FunctionMocks(BaseModel): - mocks: Dict[str, FunctionMock] - - -class FunctionTest(BaseModel): - description: str - call_params: dict - expect: dict - mocks: Dict[str, List[Dict[str, TestMock]]] - - -class FunctionData(BaseModel): - """Data required for testing this function""" - - "Function level mocks that apply for all tests of this function" - mocks: List[FunctionMock] = [] - - "All the tests for this function" - tests: List[FunctionTest] = [] - - -class WalletTest(BaseModel): - skip: Optional[bool] - function: str - description: str - funding_source: FundingSourceConfig - call_params: Optional[dict] = {} - expect: Optional[dict] - expect_error: Optional[dict] - mocks: List[Mock] = [] diff --git a/tests/wallets/fixtures/certificates/breez.crt b/tests/wallets/fixtures/certificates/breez.crt new file mode 100644 index 000000000..4168e7a0a Binary files /dev/null and b/tests/wallets/fixtures/certificates/breez.crt differ diff --git a/tests/wallets/fixtures/certificates/cert.pem b/tests/wallets/fixtures/certificates/cert.pem new file mode 100644 index 000000000..daab73e5e --- /dev/null +++ b/tests/wallets/fixtures/certificates/cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFbzCCA1egAwIBAgIUfkee1G4E8QAadd517sY/9+6xr0AwDQYJKoZIhvcNAQEL +BQAwRjELMAkGA1UEBhMCU1YxFDASBgNVBAgMC0VsIFNhbHZhZG9yMSEwHwYDVQQK +DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwNDAzMTMyMTM5WhgPMjA1 +MTA4MjAxMzIxMzlaMEYxCzAJBgNVBAYTAlNWMRQwEgYDVQQIDAtFbCBTYWx2YWRv +cjEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAnW4MKs2Y3qZnn2+J/Bp21aUuJ7oE8ll82Q2C +uh8VAlsNnGDpTyOSRLHLmxV+cu82umvVPBpOVwAl17/VuxcLjFVSk7YOMj3MWoF5 +hm+oBtetouSDt3H0+BoDuXN3eVsLI4b+e1F6ag7JIwsDQvRUbGTFiyHVvXolTZPb +wtFzlwQSB5i6KHKRQ+W6Q+cz4khIRO79IhaEiu5TWDrmx+6WkZxWYYO/g/I/S1gX +l1JP6gXQFabwUFn+CBAxPsi7f+igi6gIepXBQOIG1dkZ5ojJPabtvblO7mWJTsec +2D4Vb3L7OfboIYC85gY1cudWBX3oAASIVh9m9YoCZW2WOMNr6apnJSXx36ueJXAS +rPq3C2haPWO8z+0nYkaYTcTAxeCvs0ux2DGIniinC+u1cELg6REK2X1K8YsSsXrc +U1T8rNs2azyzTxglIHHac6ScG+Ac1nlY54C9UfZZcztE8nUBqJi+Eowpyr+y3QvT +zNdulc80xpi5arbzt85BNi+xX+NZC07QjgUJ/eexRglP3flfTbbnG8Pphe/M/l04 +IfBWBqK2cF9Fd+1J+Zf7fXZrw+41QF8WukLoQ4JQEMqIIhDFzaoTi5ogsnhiGu0Z +iaCATfCLMsWvAPHw6afFw2/utdvCd2Dr22H16hj0xEkNOw702/AoNWMFmzIzuC9m +VjkH1KUCAwEAAaNTMFEwHQYDVR0OBBYEFJAQIGLZNVRwGIgb3cmPTAiduzreMB8G +A1UdIwQYMBaAFJAQIGLZNVRwGIgb3cmPTAiduzreMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggIBAFOaWcLZSU46Zr43kQU+w+A70r+unmRfsANeREDi +Qvjg1ihJLO8g1l7Cu74QUqLwx8BG3KO7ZbDcN6uTeCrYgyERSVUxNAwu5hf2LnEr +MQ/L4h0j/8flj9oowTDCit/6YXTJ1Mf8OaKkSliUYVsoZCaIISZ2pvcZbU1cXCeX +JBM4Zr1ijM8qbghPoG6O7Ep/A3VHTozuAU9C7uREH+XJFepr9BXjrFqyzx/ArEZa +5HIO9nOqWqtwMFDE2jX3Ios3tjbU275ez2Xd7meDn0iPWMEgNbXX6b+FFlNkajR2 +NchPmBigBpk9bt63HeIQb2t/VU7X9FvMTqCbp1R2MGiHTMyQ9IjeoYKNy/mur/GG +DQkG7rq52oPGI06CJ7uuMEhCm6jNVtIbnCTl2jRnkD1fqKVmQa9Cn7jqDqR2dhqX +AxTk01Vhinxhik0ckhcgViRgiBWSnnx4Vzk7wyV6O4EdtLTywkywTR/+WEisBVUV +LOXZEmxj+AVARARUds+a/IgdANFGr/yWI6WBOibjoEFZMEZqzwlcEErgxLRinUvb +9COmr6ig+zC1570V2ktmn1P/qodOD4tOL0ICSkKoTQLFPfevM2y0DdN48T2kxzZ5 +TruiKHuAnOhvwKwUpF+TRFMUWft3VG9GJXm/4A9FWm/ALLrqw2oSXGrl5z8pq29z +SN2A +-----END CERTIFICATE----- diff --git a/tests/wallets/fixtures.json b/tests/wallets/fixtures/json/fixtures_rest.json similarity index 99% rename from tests/wallets/fixtures.json rename to tests/wallets/fixtures/json/fixtures_rest.json index 75275067f..cfe3b7802 100644 --- a/tests/wallets/fixtures.json +++ b/tests/wallets/fixtures/json/fixtures_rest.json @@ -4,7 +4,8 @@ "wallet_class": "CoreLightningRestWallet", "settings": { "corelightning_rest_url": "http://127.0.0.1:8555", - "corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn" + "corelightning_rest_macaroon": "eNcRyPtEdMaCaRoOn", + "user_agent": "LNbits/Tests" } }, "lndrest": { @@ -12,14 +13,16 @@ "settings": { "lnd_rest_endpoint": "http://127.0.0.1:8555", "lnd_rest_macaroon": "eNcRyPtEdMaCaRoOn", - "lnd_rest_cert": "" + "lnd_rest_cert": "", + "user_agent": "LNbits/Tests" } }, "alby": { "wallet_class": "AlbyWallet", "settings": { "alby_api_endpoint": "http://127.0.0.1:8555", - "alby_access_token": "mock-alby-access-token" + "alby_access_token": "mock-alby-access-token", + "user_agent": "LNbits/Tests" } } }, diff --git a/tests/wallets/fixtures/json/fixtures_rpc.json b/tests/wallets/fixtures/json/fixtures_rpc.json new file mode 100644 index 000000000..247509d19 --- /dev/null +++ b/tests/wallets/fixtures/json/fixtures_rpc.json @@ -0,0 +1,1527 @@ +{ + "funding_sources": { + "breez": { + "skip": true, + "wallet_class": "BreezSdkWallet", + "client_field": "sdk_services", + "settings": { + "breez_api_key": "100", + "breez_greenlight_seed": "push divert icon era bracket fade much kind reason injury suffer muffin", + "breez_greenlight_device_key": "tests/wallets/certificates/cert.pem", + "breez_greenlight_device_cert": "tests/wallets/certificates/breez.crt" + } + }, + "corelightning": { + "wallet_class": "CoreLightningWallet", + "client_field": "ln", + "settings": { + "corelightning_rpc": "some-mock-value" + } + } + }, + "functions": { + "status": { + "mocks": { + "breez": { + "breez_connect": { + "method": "breez_sdk.connect", + "request_type": "function" + } + }, + "corelightning": { + "rpc_new": { + "method": "pyln.client.LightningRpc.__new__", + "request_type": "function", + "response_type": "data", + "response": { + "help": { + "request_type": "function", + "response_type": "json", + "response": { + "help": [ + { + "command": "some command" + } + ] + } + }, + "listinvoices": { + "request_type": "function", + "response_type": "json", + "response": { + "invoices": [] + } + } + } + } + } + }, + "tests": [ + { + "description": "success", + "call_params": {}, + "expect": { + "error_message": null, + "balance_msat": 55000 + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "node_info": { + "request_type": "function", + "response_type": "data", + "response": { + "channels_balance_msat": 55000 + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "description": "one channel", + "response": { + "listfunds": { + "request_type": "function", + "response_type": "json", + "response": { + "channels": [ + { + "our_amount_msat": 55000 + } + ] + } + } + } + }, + { + "description": "two channels", + "response": { + "listfunds": { + "request_type": "function", + "response_type": "json", + "response": { + "channels": [ + { + "our_amount_msat": 25000 + }, + { + "our_amount_msat": 30000 + } + ] + } + } + } + } + ] + } + } + }, + { + "description": "error", + "call_params": {}, + "expect": { + "error_message": "Unable to connect, got: 'test-error'", + "balance_msat": 0 + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "node_info": { + "request_type": "function", + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "listfunds": { + "request_type": "function", + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + } + ] + } + } + }, + { + "description": "missing data", + "call_params": {}, + "expect": { + "error_message": "no data", + "balance_msat": 0 + }, + "mocks": { + "breez": { + "breez_connect": [] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "listfunds": { + "request_type": "function", + "response_type": "json", + "response": {} + } + } + } + ] + } + } + }, + { + "description": "RPC call failed:", + "call_params": {}, + "expect": { + "error_message": "RPC 'test_method' failed with 'test-error'.", + "balance_msat": 0 + }, + "mocks": { + "breez": { + "breez_connect": [] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "listfunds": { + "request_type": "function", + "response_type": "exception", + "response": { + "module": "pyln.client.lightning", + "class": "RpcError", + "data": { + "method": "test_method", + "payload": "y", + "error": "test-error" + } + } + } + } + } + ] + } + } + } + ] + }, + "create_invoice": { + "mocks": { + "breez": { + "breez_connect": { + "method": "breez_sdk.connect", + "request_type": "function" + } + }, + "corelightning": { + "rpc_new": { + "method": "pyln.client.LightningRpc.__new__", + "request_type": "function", + "response_type": "data", + "response": { + "help": { + "request_type": "function", + "response_type": "json", + "response": { + "help": [ + { + "command": "some command" + } + ] + } + }, + "listinvoices": { + "request_type": "function", + "response_type": "json", + "response": { + "invoices": [] + } + } + } + } + } + }, + "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": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "receive_payment": { + "request_type": "function", + "request_data": { + "klass": "breez_sdk.ReceivePaymentRequest", + "kwargs": { + "amount_msat": 555000, + "description": "Test Invoice", + "preimage": null, + "opening_fee_params": null, + "use_description_hash": null + } + }, + "response_type": "data", + "response": { + "ln_invoice": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "bolt11": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n" + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "description": "one channel", + "response": { + "invoice": { + "request_type": "function", + "request_data": { + "kwargs": { + "deschashonly": false, + "description": "Test Invoice", + "expiry": null, + "exposeprivatechannels": true, + "label": "test-label", + "msatoshi": 555000 + } + }, + "response_type": "json", + "response": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "bolt11": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n" + } + } + } + } + ] + } + } + }, + { + "description": "error", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "test-error" + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "receive_payment": { + "request_type": "function", + "request_data": { + "klass": "breez_sdk.ReceivePaymentRequest", + "kwargs": { + "amount_msat": 555000, + "description": "Test Invoice", + "preimage": null, + "opening_fee_params": null, + "use_description_hash": null + } + }, + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "invoice": { + "request_type": "function", + "request_data": { + "kwargs": { + "deschashonly": false, + "description": "Test Invoice", + "expiry": null, + "exposeprivatechannels": true, + "label": "test-label", + "msatoshi": 555000 + } + }, + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + }, + { + "response": { + "invoice": { + "request_type": "function", + "request_data": { + "kwargs": { + "deschashonly": false, + "description": "Test Invoice", + "expiry": null, + "exposeprivatechannels": true, + "label": "test-label", + "msatoshi": 555000 + } + }, + "response_type": "json", + "response": { + "code": -1, + "message": "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": { + "breez": { + "breez_connect": [] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "invoice": { + "request_type": "function", + "request_data": { + "kwargs": { + "deschashonly": false, + "description": "Test Invoice", + "expiry": null, + "exposeprivatechannels": true, + "label": "test-label", + "msatoshi": 555000 + } + }, + "response_type": "json", + "response": {} + } + } + } + ] + } + } + }, + { + "description": "rpc error", + "call_params": { + "amount": 555, + "memo": "Test Invoice", + "label": "test-label" + }, + "expect": { + "success": false, + "checking_id": null, + "payment_request": null, + "error_message": "RPC 'test_method' failed with 'test-error'." + }, + "mocks": { + "breez": { + "breez_connect": [] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "invoice": { + "request_type": "function", + "request_data": { + "kwargs": { + "deschashonly": false, + "description": "Test Invoice", + "expiry": null, + "exposeprivatechannels": true, + "label": "test-label", + "msatoshi": 555000 + } + }, + "response_type": "exception", + "response": { + "module": "pyln.client.lightning", + "class": "RpcError", + "data": { + "method": "test_method", + "payload": "y", + "error": "test-error" + } + } + } + } + } + ] + } + } + } + ] + }, + "pay_invoice": { + "mocks": { + "breez": { + "breez_connect": { + "method": "breez_sdk.connect", + "request_type": "function" + } + }, + "corelightning": { + "rpc_new": { + "method": "pyln.client.LightningRpc.__new__", + "request_type": "function", + "response_type": "data", + "response": { + "help": { + "request_type": "function", + "response_type": "json", + "response": { + "help": [ + { + "command": "some command" + } + ] + } + }, + "listinvoices": { + "request_type": "function", + "response_type": "json", + "response": { + "invoices": [] + } + }, + "listpays": { + "description": "no data, pending", + "request_type": "function", + "response_type": "json", + "response": {} + } + } + } + } + }, + "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": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "send_payment": { + "request_type": "function", + "response_type": "data", + "response": { + "status": { + "value": 2 + }, + "fee_msat": 50, + "details": { + "data": { + "payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "call": { + "description": "indirect call to `pay` (via `call`)", + "request_type": "function", + "request_data": { + "args": [ + "pay", + { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "description": "Unit Test Invoice", + "exemptfee": 0, + "maxfeepercent": "119.04761905" + } + ] + }, + "response_type": "json", + "response": { + "amount_sent_msat": 21000, + "amount_msat": 21050, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + } + } + ] + } + } + }, + { + "description": "error", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "Payment failed: 'test-error'." + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "send_payment": { + "request_type": "function", + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "call": { + "description": "indirect call to `pay` (via `call`)", + "request_type": "function", + "request_data": { + "args": [ + "pay", + { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "description": "Unit Test Invoice", + "exemptfee": 0, + "maxfeepercent": "119.04761905" + } + ] + }, + "response_type": "exception", + "response": { + "data": "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": { + "breez": { + "breez_connect": [] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "call": { + "description": "indirect call to `pay` (via `call`)", + "request_type": "function", + "request_data": { + "args": [ + "pay", + { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "description": "Unit Test Invoice", + "exemptfee": 0, + "maxfeepercent": "119.04761905" + } + ] + }, + "response_type": "json", + "response": {} + } + } + } + ] + } + } + }, + { + "description": "rpc error", + "call_params": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "fee_limit_msat": 25000 + }, + "expect": { + "success": false, + "checking_id": null, + "fee_msat": null, + "preimage": null, + "error_message": "RPC 'test_method' failed with 'test-error'." + }, + "mocks": { + "breez": { + "breez_connect": [] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "call": { + "description": "indirect call to `pay` (via `call`)", + "request_type": "function", + "request_data": { + "args": [ + "pay", + { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "description": "Unit Test Invoice", + "exemptfee": 0, + "maxfeepercent": "119.04761905" + } + ] + }, + "response_type": "exception", + "response": { + "module": "pyln.client.lightning", + "class": "RpcError", + "data": { + "method": "test_method", + "payload": "y", + "error": { + "attempts": [ + { + "fail_reason": "RPC 'test_method' failed with 'test-error'." + } + ] + } + } + } + } + } + }, + { + "response": { + "call": { + "description": "indirect call to `pay` (via `call`)", + "request_type": "function", + "request_data": { + "args": [ + "pay", + { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "description": "Unit Test Invoice", + "exemptfee": 0, + "maxfeepercent": "119.04761905" + } + ] + }, + "response_type": "exception", + "response": { + "module": "pyln.client.lightning", + "class": "RpcError", + "data": { + "method": "test_method", + "payload": "y", + "error": "test-error" + } + } + } + } + } + ] + } + } + } + ] + }, + "get_invoice_status": { + "mocks": { + "breez": { + "breez_connect": { + "method": "breez_sdk.connect", + "request_type": "function" + } + }, + "corelightning": { + "rpc_new": { + "method": "pyln.client.LightningRpc.__new__", + "request_type": "function", + "response_type": "data", + "response": { + "help": { + "request_type": "function", + "response_type": "json", + "response": { + "help": [ + { + "command": "some command" + } + ] + } + } + } + } + } + }, + "tests": [ + { + "description": "success", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": true, + "failed": false, + "pending": false + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": { + "payment_type": { + "value": 2 + }, + "status": { + "value": 2 + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "description": "one invoice", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "response_type": "json", + "response": { + "invoices": [ + { + "status": "paid", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + ] + } + } + ] + } + } + ] + } + } + }, + { + "description": "pending", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": false, + "failed": false, + "pending": true + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": null + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "description": "no invoice", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + } + ] + } + }, + { + "description": "rpc error", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "exception", + "response": { + "module": "pyln.client.lightning", + "class": "RpcError", + "data": { + "method": "test_method", + "payload": "y", + "error": "test-error" + } + } + } + ] + } + }, + { + "description": "error", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "exception", + "response": { + "data": "test-error" + } + } + ] + } + }, + { + "description": "no data", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": {} + } + ] + } + }, + { + "description": "bad checking_id", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [ + { + "status": "paid", + "payment_hash": "baaade35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d72" + } + ] + } + } + ] + } + }, + { + "description": "unpaid", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [ + { + "status": "unpaid", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + ] + } + } + ] + } + } + ] + } + } + }, + { + "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": { + "breez": { + "breez_connect": [ + { + "description": "FAILED", + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": { + "payment_type": { + "value": 2 + }, + "status": { + "value": 1 + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "description": "expired", + "response": { + "listinvoices": [ + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [] + } + }, + { + "request_type": "function", + "request_data": { + "kwargs": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + }, + "response_type": "json", + "response": { + "invoices": [ + { + "status": "expired", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + ] + } + } + ] + } + } + ] + } + } + } + ] + }, + "get_payment_status": { + "mocks": { + "breez": { + "breez_connect": { + "method": "breez_sdk.connect", + "request_type": "function" + } + }, + "corelightning": { + "rpc_new": { + "method": "pyln.client.LightningRpc.__new__", + "request_type": "function", + "response_type": "data", + "response": { + "help": { + "request_type": "function", + "response_type": "json", + "response": { + "help": [ + { + "command": "some command" + } + ] + } + }, + "listinvoices": { + "request_type": "function", + "response_type": "json", + "response": { + "invoices": [] + } + } + } + } + } + }, + "tests": [ + { + "description": "success", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": true, + "failed": false, + "pending": false, + "fee_msat": 50, + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": { + "fee_msat": 50, + "details": { + "data": { + "payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "payment_type": { + "value": 1 + }, + "status": { + "value": 2 + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "listpays": [ + { + "request_type": "function", + "response_type": "json", + "response": { + "pays": [ + { + "status": "complete", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "preimage": "0000000000000000000000000000000000000000000000000000000000000000", + "amount_sent_msat": 21000, + "amount_msat": 21050 + } + ] + } + } + ] + } + } + ] + } + } + }, + { + "description": "pending", + "call_params": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "expect": { + "success": false, + "failed": false, + "pending": true, + "fee_msat": null + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "description": "no data", + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": null + } + } + }, + { + "description": "error", + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + }, + { + "description": "pending", + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": { + "status": { + "value": 1 + }, + "payment_type": { + "value": 1 + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "listpays": [ + { + "description": "no data", + "request_type": "function", + "response_type": "json", + "response": {} + } + ] + } + }, + { + "description": "error", + "response_type": "data", + "response": { + "listpays": { + "request_type": "function", + "response_type": "exception", + "response": { + "data": "test-error" + } + } + } + }, + { + "response": { + "listpays": [ + { + "description": "pending status", + "request_type": "function", + "response_type": "json", + "response": { + "pays": [ + { + "status": "pending", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + ] + } + } + ] + } + }, + { + "response": { + "listpays": [ + { + "description": "bad checking_id", + "request_type": "function", + "response_type": "json", + "response": { + "pays": [ + { + "status": "complete", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d7bbbbbb" + } + ] + } + } + ] + } + } + ] + } + } + }, + { + "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, + "fee_msat": null, + "preimage": null + }, + "mocks": { + "breez": { + "breez_connect": [ + { + "description": "failed status", + "response_type": "data", + "response": { + "payment_by_hash": { + "request_type": "function", + "response_type": "data", + "response": { + "status": { + "value": 3 + }, + "payment_type": { + "value": 1 + } + } + } + } + } + ] + }, + "corelightning": { + "rpc_new": [ + { + "response": { + "listpays": [ + { + "request_type": "function", + "response_type": "json", + "response": { + "pays": [ + { + "status": "failed", + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + ] + } + } + ] + } + } + ] + } + } + } + ] + } + } +} diff --git a/tests/wallets/fixtures/models.py b/tests/wallets/fixtures/models.py new file mode 100644 index 000000000..33ecf9fbc --- /dev/null +++ b/tests/wallets/fixtures/models.py @@ -0,0 +1,134 @@ +from typing import Dict, List, Optional, Union + +from pydantic import BaseModel + + +class FundingSourceConfig(BaseModel): + name: str + skip: Optional[bool] + wallet_class: str + client_field: Optional[str] + settings: dict + + +class FunctionMock(BaseModel): + uri: Optional[str] + query_params: Optional[dict] + headers: Optional[dict] + method: Optional[str] + + +class TestMock(BaseModel): + skip: Optional[bool] + description: Optional[str] + request_type: Optional[str] + request_body: Optional[dict] + response_type: str + response: Union[str, dict] + + +class Mock(FunctionMock, TestMock): + + @staticmethod + def combine_mocks(fs_mock, test_mock): + _mock = fs_mock | test_mock + if "response" in _mock and "response" in fs_mock: + _mock["response"] |= fs_mock["response"] + return Mock(**_mock) + + +class FunctionMocks(BaseModel): + mocks: Dict[str, FunctionMock] + + +class FunctionTest(BaseModel): + description: str + call_params: dict + expect: dict + mocks: Dict[str, List[Dict[str, TestMock]]] + + +class FunctionData(BaseModel): + """Data required for testing this function""" + + "Function level mocks that apply for all tests of this function" + mocks: List[FunctionMock] = [] + + "All the tests for this function" + tests: List[FunctionTest] = [] + + +class WalletTest(BaseModel): + skip: Optional[bool] + function: str + description: str + funding_source: FundingSourceConfig + call_params: Optional[dict] = {} + expect: Optional[dict] + expect_error: Optional[dict] + mocks: List[Mock] = [] + + @staticmethod + def tests_for_funding_source( + fs: FundingSourceConfig, + fn_name: str, + fn, + test, + ) -> List["WalletTest"]: + t = WalletTest( + **{ + "funding_source": fs, + "function": fn_name, + **test, + "mocks": [], + "skip": fs.skip, + } + ) + if "mocks" in test: + if fs.name not in test["mocks"]: + t.skip = True + return [t] + + return t._tests_from_fs_mocks(fn, test, fs.name) + + return [t] + + def _tests_from_fs_mocks(self, fn, test, fs_name: str) -> List["WalletTest"]: + tests: List[WalletTest] = [] + + fs_mocks = fn["mocks"][fs_name] + test_mocks = test["mocks"][fs_name] + + for mock_name in fs_mocks: + tests += self._tests_from_mocks(fs_mocks[mock_name], test_mocks[mock_name]) + return tests + + def _tests_from_mocks(self, fs_mock, test_mocks) -> List["WalletTest"]: + tests: List[WalletTest] = [] + for test_mock in test_mocks: + # different mocks that result in the same + # return value for the tested function + unique_test = self._test_from_mocks(fs_mock, test_mock) + + tests.append(unique_test) + return tests + + def _test_from_mocks(self, fs_mock, test_mock) -> "WalletTest": + mock = Mock.combine_mocks(fs_mock, test_mock) + + return WalletTest( + **( + self.dict() + | { + "description": f"""{self.description}:{mock.description or ""}""", + "mocks": self.mocks + [mock], + "skip": self.skip or mock.skip, + } + ) + ) + + +class DataObject: + def __init__(self, **kwargs): + for k in kwargs: + setattr(self, k, kwargs[k]) diff --git a/tests/wallets/helpers.py b/tests/wallets/helpers.py new file mode 100644 index 000000000..8b2d1db1e --- /dev/null +++ b/tests/wallets/helpers.py @@ -0,0 +1,117 @@ +import importlib +import json +from typing import Dict, List + +import pytest + +from lnbits.core.models import BaseWallet +from tests.wallets.fixtures.models import FundingSourceConfig, WalletTest + +wallets_module = importlib.import_module("lnbits.wallets") + + +def wallet_fixtures_from_json(path) -> List["WalletTest"]: + with open(path) as f: + data = json.load(f) + + funding_sources = [ + FundingSourceConfig(name=fs_name, **data["funding_sources"][fs_name]) + for fs_name in data["funding_sources"] + ] + tests: Dict[str, List[WalletTest]] = {} + for fn_name in data["functions"]: + fn = data["functions"][fn_name] + fn_tests = _tests_for_function(funding_sources, fn_name, fn) + _merge_dict_of_lists(tests, fn_tests) + + all_tests = sum([tests[fs_name] for fs_name in tests], []) + return all_tests + + +def _tests_for_function( + funding_sources: List[FundingSourceConfig], fn_name: str, fn +) -> Dict[str, List[WalletTest]]: + tests: Dict[str, List[WalletTest]] = {} + for test in fn["tests"]: + """create an unit test for each funding source""" + + fs_tests = _tests_for_funding_source(funding_sources, fn_name, fn, test) + _merge_dict_of_lists(tests, fs_tests) + + return tests + + +def _tests_for_funding_source( + funding_sources: List[FundingSourceConfig], fn_name: str, fn, test +) -> Dict[str, List[WalletTest]]: + tests: Dict[str, List[WalletTest]] = {fs.name: [] for fs in funding_sources} + for fs in funding_sources: + tests[fs.name] += WalletTest.tests_for_funding_source(fs, fn_name, fn, test) + return tests + + +def build_test_id(test: WalletTest): + return f"{test.funding_source}.{test.function}({test.description})" + + +def load_funding_source(funding_source: FundingSourceConfig) -> BaseWallet: + custom_settings = funding_source.settings + original_settings = {} + + settings = getattr(wallets_module, "settings") + + for s in custom_settings: + original_settings[s] = getattr(settings, s) + setattr(settings, s, custom_settings[s]) + + fs_instance: BaseWallet = getattr(wallets_module, funding_source.wallet_class)() + + # rollback settings (global variable) + for s in original_settings: + setattr(settings, s, original_settings[s]) + + return fs_instance + + +async def check_assertions(wallet, _test_data: WalletTest): + test_data = _test_data.dict() + tested_func = _test_data.function + call_params = _test_data.call_params + + if "expect" in test_data: + await _assert_data(wallet, tested_func, call_params, _test_data.expect) + # if len(_test_data.mocks) == 0: + # # all calls should fail after this method is called + # await wallet.cleanup() + # # same behaviour expected is server canot be reached + # # or if the connection was closed + # await _assert_data(wallet, tested_func, call_params, _test_data.expect) + elif "expect_error" in test_data: + await _assert_error(wallet, tested_func, call_params, _test_data.expect_error) + else: + assert False, "Expected outcome not specified" + + +async def _assert_data(wallet, tested_func, call_params, expect): + resp = await getattr(wallet, tested_func)(**call_params) + for key in expect: + received = getattr(resp, key) + expected = expect[key] + assert ( + getattr(resp, key) == expect[key] + ), f"""Field "{key}". Received: "{received}". Expected: "{expected}".""" + + +async def _assert_error(wallet, tested_func, call_params, expect_error): + error_module = importlib.import_module(expect_error["module"]) + error_class = getattr(error_module, expect_error["class"]) + with pytest.raises(error_class) as e_info: + await getattr(wallet, tested_func)(**call_params) + + assert e_info.match(expect_error["message"]) + + +def _merge_dict_of_lists(v1: Dict[str, List], v2: Dict[str, List]): + """Merge v2 into v1""" + for k in v2: + v1[k] = v2[k] if k not in v1 else v1[k] + v2[k] diff --git a/tests/wallets/test_rest_wallets.py b/tests/wallets/test_rest_wallets.py index 2cf26f6dd..d4e4497cf 100644 --- a/tests/wallets/test_rest_wallets.py +++ b/tests/wallets/test_rest_wallets.py @@ -1,4 +1,3 @@ -import importlib import json from typing import Dict, Union from urllib.parse import urlencode @@ -7,16 +6,15 @@ import pytest from pytest_httpserver import HTTPServer from werkzeug.wrappers import Response -from lnbits.core.models import BaseWallet -from tests.helpers import ( - FundingSourceConfig, - Mock, +from tests.wallets.fixtures.models import Mock +from tests.wallets.helpers import ( WalletTest, - rest_wallet_fixtures_from_json, + build_test_id, + check_assertions, + load_funding_source, + wallet_fixtures_from_json, ) -wallets_module = importlib.import_module("lnbits.wallets") - # todo: # - tests for extra fields # - tests for paid_invoices_stream @@ -29,14 +27,10 @@ def httpserver_listen_address(): return ("127.0.0.1", 8555) -def build_test_id(test: WalletTest): - return f"{test.funding_source}.{test.function}({test.description})" - - @pytest.mark.asyncio @pytest.mark.parametrize( "test_data", - rest_wallet_fixtures_from_json("tests/wallets/fixtures.json"), + wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_rest.json"), ids=build_test_id, ) async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest): @@ -46,8 +40,8 @@ async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest): for mock in test_data.mocks: _apply_mock(httpserver, mock) - wallet = _load_funding_source(test_data.funding_source) - await _check_assertions(wallet, test_data) + wallet = load_funding_source(test_data.funding_source) + await check_assertions(wallet, test_data) def _apply_mock(httpserver: HTTPServer, mock: Mock): @@ -65,6 +59,8 @@ def _apply_mock(httpserver: HTTPServer, mock: Mock): if mock.query_params: request_data["query_string"] = mock.query_params + assert mock.uri, "Missing URI for HTTP mock." + assert mock.method, "Missing method for HTTP mock." req = httpserver.expect_request( uri=mock.uri, headers=mock.headers, @@ -84,60 +80,3 @@ def _apply_mock(httpserver: HTTPServer, mock: Mock): respond_with = f"respond_with_{response_type}" getattr(req, respond_with)(server_response) - - -async def _check_assertions(wallet, _test_data: WalletTest): - test_data = _test_data.dict() - tested_func = _test_data.function - call_params = _test_data.call_params - - if "expect" in test_data: - await _assert_data(wallet, tested_func, call_params, _test_data.expect) - # if len(_test_data.mocks) == 0: - # # all calls should fail after this method is called - # await wallet.cleanup() - # # same behaviour expected is server canot be reached - # # or if the connection was closed - # await _assert_data(wallet, tested_func, call_params, _test_data.expect) - elif "expect_error" in test_data: - await _assert_error(wallet, tested_func, call_params, _test_data.expect_error) - else: - assert False, "Expected outcome not specified" - - -async def _assert_data(wallet, tested_func, call_params, expect): - resp = await getattr(wallet, tested_func)(**call_params) - for key in expect: - received = getattr(resp, key) - expected = expect[key] - assert ( - getattr(resp, key) == expect[key] - ), f"""Field "{key}". Received: "{received}". Expected: "{expected}".""" - - -async def _assert_error(wallet, tested_func, call_params, expect_error): - error_module = importlib.import_module(expect_error["module"]) - error_class = getattr(error_module, expect_error["class"]) - with pytest.raises(error_class) as e_info: - await getattr(wallet, tested_func)(**call_params) - - assert e_info.match(expect_error["message"]) - - -def _load_funding_source(funding_source: FundingSourceConfig) -> BaseWallet: - custom_settings = funding_source.settings | {"user_agent": "LNbits/Tests"} - original_settings = {} - - settings = getattr(wallets_module, "settings") - - for s in custom_settings: - original_settings[s] = getattr(settings, s) - setattr(settings, s, custom_settings[s]) - - fs_instance: BaseWallet = getattr(wallets_module, funding_source.wallet_class)() - - # rollback settings (global variable) - for s in original_settings: - setattr(settings, s, original_settings[s]) - - return fs_instance diff --git a/tests/wallets/test_rpc_wallets.py b/tests/wallets/test_rpc_wallets.py new file mode 100644 index 000000000..960f72289 --- /dev/null +++ b/tests/wallets/test_rpc_wallets.py @@ -0,0 +1,145 @@ +import importlib +from typing import Dict, List, Optional + +import pytest +from mock import Mock +from pytest_mock.plugin import MockerFixture + +from lnbits.core.models import BaseWallet +from tests.wallets.fixtures.models import DataObject +from tests.wallets.fixtures.models import Mock as RpcMock +from tests.wallets.helpers import ( + WalletTest, + build_test_id, + check_assertions, + load_funding_source, + wallet_fixtures_from_json, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "test_data", + wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_rpc.json"), + ids=build_test_id, +) +async def test_wallets(mocker: MockerFixture, test_data: WalletTest): + if test_data.skip: + pytest.skip() + + for mock in test_data.mocks: + _apply_rpc_mock(mocker, mock) + + wallet = load_funding_source(test_data.funding_source) + + expected_calls = _spy_mocks(mocker, test_data, wallet) + + await check_assertions(wallet, test_data) + + _check_calls(expected_calls) + + +def _apply_rpc_mock(mocker: MockerFixture, mock: RpcMock): + return_value = {} + assert isinstance(mock.response, dict), "Expected data RPC response" + for field_name in mock.response: + value = mock.response[field_name] + values = value if isinstance(value, list) else [value] + + return_value[field_name] = Mock(side_effect=[_mock_field(f) for f in values]) + + m = _data_mock(return_value) + assert mock.method, "Missing method for RPC mock." + mocker.patch(mock.method, m) + + +def _check_calls(expected_calls): + for func in expected_calls: + func_calls = expected_calls[func] + for func_call in func_calls: + req = func_call["request_data"] + args = req["args"] if "args" in req else {} + kwargs = req["kwargs"] if "kwargs" in req else {} + if "klass" in req: + *rest, cls = req["klass"].split(".") + req_module = importlib.import_module(".".join(rest)) + req_class = getattr(req_module, cls) + func_call["spy"].assert_called_with(req_class(*args, **kwargs)) + else: + func_call["spy"].assert_called_with(*args, **kwargs) + + +def _spy_mocks(mocker: MockerFixture, test_data: WalletTest, wallet: BaseWallet): + assert ( + test_data.funding_source.client_field + ), f"Missing client field for wallet {wallet}" + client_field = getattr(wallet, test_data.funding_source.client_field) + expected_calls: Dict[str, List] = {} + for mock in test_data.mocks: + spy = _spy_mock(mocker, mock, client_field) + expected_calls |= spy + + return expected_calls + + +def _spy_mock(mocker: MockerFixture, mock: RpcMock, client_field): + expected_calls: Dict[str, List] = {} + assert isinstance(mock.response, dict), "Expected data RPC response" + for field_name in mock.response: + value = mock.response[field_name] + values = value if isinstance(value, list) else [value] + + expected_calls[field_name] = [ + { + "spy": mocker.spy(client_field, field_name), + "request_data": f["request_data"], + } + for f in values + if f["request_type"] == "function" and "request_data" in f + ] + return expected_calls + + +def _mock_field(field): + response_type = field["response_type"] + request_type = field["request_type"] + response = field["response"] + + if request_type == "data": + return _dict_to_object(response) + + if request_type == "function": + if response_type == "data": + return _dict_to_object(response) + + if response_type == "exception": + return _raise(response) + + return response + + +def _dict_to_object(data: Optional[dict]) -> Optional[DataObject]: + if not data: + return None + d = {**data} + for k in data: + value = data[k] + if isinstance(value, dict): + d[k] = _dict_to_object(value) + + return DataObject(**d) + + +def _data_mock(data: dict) -> Mock: + return Mock(return_value=_dict_to_object(data)) + + +def _raise(error: dict): + data = error["data"] if "data" in error else None + if "module" not in error or "class" not in error: + return Exception(data) + + error_module = importlib.import_module(error["module"]) + error_class = getattr(error_module, error["class"]) + + return error_class(**data)