From ea58b51619b003bf54c40b8991844a211d98533b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 8 Apr 2024 13:26:00 +0300 Subject: [PATCH] test: add tests for alby (#2390) * test: initial commit * chore: code format * fix: comment out bad `status.pending` (to be fixed in core) * fix: 404 tests * test: extract first `create_invoice` test * chore: reminder * add: error test * chore: experiment * feat: adapt parsing * refactor: data structure * fix: some tests * 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 * 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 * 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: start to extract data model * chore: final clean-up * chore: rename file * test: add tests for alby * refactor: `KeyError` handling * chore: log error * chore: skip the negative fee test * fix: error message fetching --- lnbits/wallets/alby.py | 150 ++++++++++---- lnbits/wallets/lndrest.py | 2 - tests/helpers.py | 9 + tests/wallets/fixtures.json | 319 +++++++++++++++++++++++++++++ tests/wallets/test_rest_wallets.py | 3 + 5 files changed, 436 insertions(+), 47 deletions(-) diff --git a/lnbits/wallets/alby.py b/lnbits/wallets/alby.py index b7977f846..69a9509b0 100644 --- a/lnbits/wallets/alby.py +++ b/lnbits/wallets/alby.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import json from typing import AsyncGenerator, Dict, Optional import httpx @@ -42,17 +43,28 @@ class AlbyWallet(Wallet): async def status(self) -> StatusResponse: try: r = await self.client.get("/balance", timeout=10) - except (httpx.ConnectError, httpx.RequestError): - return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0) + r.raise_for_status() - if r.is_error: - error_message = r.json()["message"] - return StatusResponse(error_message, 0) + data = r.json() - data = r.json() - assert data["unit"] == "sat" - # multiply balance by 1000 to get msats balance - return StatusResponse(None, data["balance"] * 1000) + if len(data) == 0: + return StatusResponse("no data", 0) + + if r.is_error or data["unit"] != "sat": + error_message = data["message"] if "message" in data else r.text + return StatusResponse(f"Server error: '{error_message}'", 0) + + # multiply balance by 1000 to get msats balance + return StatusResponse(None, data["balance"] * 1000) + except KeyError as exc: + logger.warning(exc) + return StatusResponse("Server error: 'missing required fields'", 0) + except json.JSONDecodeError as exc: + logger.warning(exc) + 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) async def create_invoice( self, @@ -71,57 +83,105 @@ class AlbyWallet(Wallet): else: data["memo"] = memo or "" - r = await self.client.post( - "/invoices", - json=data, - timeout=40, - ) + try: + r = await self.client.post( + "/invoices", + json=data, + timeout=40, + ) + r.raise_for_status() - if r.is_error: - error_message = r.json()["message"] - return InvoiceResponse(False, None, None, error_message) + data = r.json() - data = r.json() - checking_id = data["payment_hash"] - payment_request = data["payment_request"] - return InvoiceResponse(True, checking_id, payment_request, None) + if r.is_error: + error_message = data["message"] if "message" in data else r.text + return InvoiceResponse(False, None, None, error_message) + + checking_id = data["payment_hash"] + payment_request = data["payment_request"] + return InvoiceResponse(True, checking_id, payment_request, None) + except KeyError as exc: + logger.warning(exc) + return InvoiceResponse( + False, None, None, "Server error: 'missing required fields'" + ) + except json.JSONDecodeError as exc: + logger.warning(exc) + 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: - # https://api.getalby.com/payments/bolt11 - r = await self.client.post( - "/payments/bolt11", - json={"invoice": bolt11}, # assume never need amount in body - timeout=None, - ) + try: + # https://api.getalby.com/payments/bolt11 + r = await self.client.post( + "/payments/bolt11", + json={"invoice": bolt11}, # assume never need amount in body + timeout=None, + ) + r.raise_for_status() + data = r.json() - if r.is_error: - error_message = r.json()["message"] - return PaymentResponse(False, None, None, None, error_message) + if r.is_error: + error_message = data["message"] if "message" in data else r.text + return PaymentResponse(False, None, None, None, error_message) - data = r.json() - checking_id = data["payment_hash"] - fee_msat = -data["fee"] - preimage = data["payment_preimage"] + checking_id = data["payment_hash"] + # todo: confirm with bitkarrot that having the minus is fine + # other funding sources return a positive fee value + fee_msat = -data["fee"] + preimage = data["payment_preimage"] - return PaymentResponse(True, checking_id, fee_msat, preimage, None) + return PaymentResponse(True, checking_id, fee_msat, preimage, None) + except KeyError as exc: + logger.warning(exc) + return PaymentResponse( + False, None, None, None, "Server error: 'missing required fields'" + ) + except json.JSONDecodeError as exc: + logger.warning(exc) + 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.endpoint}." + ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: return await self.get_payment_status(checking_id) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = await self.client.get(f"/invoices/{checking_id}") + try: + r = await self.client.get(f"/invoices/{checking_id}") - if r.is_error: + if r.is_error: + return PaymentPendingStatus() + + data = r.json() + + statuses = { + "CREATED": None, + "SETTLED": True, + } + # todo: extract fee and preimage + # maybe use the more specific endpoints: + # - https://api.getalby.com/invoices/incoming + # - https://api.getalby.com/invoices/outgoing + return PaymentStatus( + statuses[data.get("state")], fee_msat=None, preimage=None + ) + except Exception as e: + logger.error(f"Error getting invoice status: {e}") return PaymentPendingStatus() - data = r.json() - - statuses = { - "CREATED": None, - "SETTLED": True, - } - return PaymentStatus(statuses[data.get("state")], fee_msat=None, preimage=None) - async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: self.queue: asyncio.Queue = asyncio.Queue(0) while True: diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 31780b4da..32c3a0e1d 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -89,10 +89,8 @@ class LndRestWallet(Wallet): r.raise_for_status() data = r.json() - 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) diff --git a/tests/helpers.py b/tests/helpers.py index e950be38b..0719119c4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -209,7 +209,13 @@ def rest_wallet_fixtures_from_json(path) -> List["WalletTest"]: } ) 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]: @@ -223,6 +229,7 @@ def rest_wallet_fixtures_from_json(path) -> List["WalletTest"]: f"""{t.description}:{mock.description or ""}""" ) unique_test.mocks = t.mocks + [mock] + unique_test.skip = mock.skip tests[fs_name].append(unique_test) else: @@ -246,6 +253,7 @@ class FunctionMock(BaseModel): class TestMock(BaseModel): + skip: Optional[bool] description: Optional[str] request_type: Optional[str] request_body: Optional[dict] @@ -279,6 +287,7 @@ class FunctionData(BaseModel): class WalletTest(BaseModel): + skip: Optional[bool] function: str description: str funding_source: FundingSourceConfig diff --git a/tests/wallets/fixtures.json b/tests/wallets/fixtures.json index bb9aea149..75275067f 100644 --- a/tests/wallets/fixtures.json +++ b/tests/wallets/fixtures.json @@ -14,6 +14,13 @@ "lnd_rest_macaroon": "eNcRyPtEdMaCaRoOn", "lnd_rest_cert": "" } + }, + "alby": { + "wallet_class": "AlbyWallet", + "settings": { + "alby_api_endpoint": "http://127.0.0.1:8555", + "alby_access_token": "mock-alby-access-token" + } } }, "functions": { @@ -40,6 +47,16 @@ }, "method": "GET" } + }, + "alby": { + "status_endpoint": { + "uri": "/balance", + "headers": { + "Authorization": "Bearer mock-alby-access-token", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } } }, "tests": [ @@ -70,6 +87,17 @@ } } ] + }, + "alby": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "balance": 55, + "unit": "sat" + } + } + ] } } }, @@ -98,6 +126,17 @@ "response": "test-error" } ] + }, + "alby": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "message": "\"test-error\"", + "unit": "sats" + } + } + ] } } }, @@ -124,6 +163,14 @@ "response": {} } ] + }, + "alby": { + "status_endpoint": [ + { + "response_type": "json", + "response": {} + } + ] } } }, @@ -150,6 +197,14 @@ "response": "data-not-json" } ] + }, + "alby": { + "status_endpoint": [ + { + "response_type": "data", + "response": "data-not-json" + } + ] } } }, @@ -182,6 +237,17 @@ } } ] + }, + "alby": { + "status_endpoint": [ + { + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -218,6 +284,16 @@ }, "method": "POST" } + }, + "alby": { + "create_invoice_endpoint": { + "uri": "/invoices", + "headers": { + "Authorization": "Bearer mock-alby-access-token", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } } }, "tests": [ @@ -268,6 +344,22 @@ } } ] + }, + "alby": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice" + }, + "response_type": "json", + "response": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n" + } + } + ] } } }, @@ -316,6 +408,9 @@ } } ] + }, + "alby": { + "create_invoice_endpoint": [] } } }, @@ -364,6 +459,22 @@ } } ] + }, + "alby": { + "create_invoice_endpoint": [ + { + "description": "missing payment request", + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice" + }, + "response_type": "json", + "response": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + } + } + ] } } }, @@ -408,6 +519,19 @@ "response": "data-not-json" } ] + }, + "alby": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice" + }, + "response_type": "data", + "response": "data-not-json" + } + ] } } }, @@ -458,6 +582,22 @@ } } ] + }, + "alby": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "value": 555, + "memo": "Test Invoice" + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -500,6 +640,16 @@ }, "method": "POST" } + }, + "alby": { + "pay_invoice_endpoint": { + "uri": "/payments/bolt11", + "headers": { + "Authorization": "Bearer mock-alby-access-token", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } } }, "tests": [ @@ -555,6 +705,23 @@ } } ] + }, + "alby": { + "pay_invoice_endpoint": [ + { + "skip": true, + "request_type": "json", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu" + }, + "response_type": "json", + "response": { + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "fee": 50, + "payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + ] } } }, @@ -602,6 +769,9 @@ } } ] + }, + "alby": { + "pay_invoice_endpoint": [] } } }, @@ -645,6 +815,18 @@ "response": {} } ] + }, + "alby": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu" + }, + "response_type": "json", + "response": {} + } + ] } } }, @@ -688,6 +870,18 @@ "response": "data-not-json" } ] + }, + "alby": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu" + }, + "response_type": "data", + "response": "data-not-json" + } + ] } } }, @@ -737,6 +931,21 @@ } } ] + }, + "alby": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu" + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -782,6 +991,16 @@ }, "method": "GET" } + }, + "alby": { + "get_invoice_status_endpoint": { + "uri": "/invoices/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "headers": { + "Authorization": "Bearer mock-alby-access-token", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } } }, "tests": [ @@ -829,6 +1048,16 @@ } } ] + }, + "alby": { + "get_invoice_status_endpoint": [ + { + "response_type": "json", + "response": { + "state": "SETTLED" + } + } + ] } } }, @@ -861,6 +1090,10 @@ "lndrest": { "description": "lndrest.py doesn't handle the 'failed' status for `get_invoice_status`", "get_invoice_status_endpoint": [] + }, + "alby": { + "description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`", + "get_invoice_status_endpoint": [] } } }, @@ -932,6 +1165,35 @@ } } ] + }, + "alby": { + "get_invoice_status_endpoint": [ + { + "description": "error status", + "response_type": "json", + "response": {} + }, + { + "description": "error status", + "response_type": "json", + "response": { + "status": "CREATED" + } + }, + { + "description": "bad json", + "response_type": "data", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -974,6 +1236,16 @@ }, "method": "GET" } + }, + "alby": { + "get_payment_status_endpoint": { + "uri": "/invoices/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "headers": { + "Authorization": "Bearer mock-alby-access-token", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } } }, "tests": [ @@ -1019,6 +1291,19 @@ } } ] + }, + "alby": { + "get_payment_status_endpoint": [ + { + "skip": true, + "response_type": "json", + "response": { + "result": { + "status": "SETTLED" + } + } + } + ] } } }, @@ -1070,6 +1355,9 @@ } } ] + }, + "alby": { + "get_payment_status_endpoint": [] } } }, @@ -1174,6 +1462,37 @@ } } ] + }, + "alby": { + "get_payment_status_endpoint": [ + { + "description": "CREATED", + "response_type": "stream", + "response": { + "result": { + "state": "CREATED" + } + } + }, + { + "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 + } + } + ] } } }, diff --git a/tests/wallets/test_rest_wallets.py b/tests/wallets/test_rest_wallets.py index e10f7409c..2cf26f6dd 100644 --- a/tests/wallets/test_rest_wallets.py +++ b/tests/wallets/test_rest_wallets.py @@ -40,6 +40,9 @@ def build_test_id(test: WalletTest): ids=build_test_id, ) async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest): + if test_data.skip: + pytest.skip() + for mock in test_data.mocks: _apply_mock(httpserver, mock)