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
This commit is contained in:
Vlad Stan 2024-04-08 13:26:00 +03:00 committed by GitHub
parent bfda0b62da
commit ea58b51619
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 436 additions and 47 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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
}
}
]
}
}
},

View File

@ -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)