test: add unit tests for wallets (funding sources) (#2363)

* test: initial commit

* feat: allow external label for `create_invoice` (useful for testing)

* chore: code format

* fix: ignore temp coverage files

* feat: add properties to the Status classes for a better readability

* fix: add extra validation for data

* fix: comment out bad `status.pending` (to be fixed in core)

* fix: 404 tests

* test: first draft of generic rest wallet tests

* test: migrate two more tests

* feat: add response type

* feat: test exceptions

* test: extract first `create_invoice` test

* chore: reminder

* add: error test

* chore: code format

* chore: experiment

* feat: adapt parsing

* refactor: data structure

* fix: some tests

* refactor: extract methods

* fix: make response uniform

* fix: test data

* chore: clean-up

* fix: uniform responses

* fix: user agent

* fix: user agent

* fix: user-agent again

* test: add `with error` test

* feat: customize test name

* fix: better exception handling for `status`

* fix: add `try-catch` for `raise_for_status`

* test: with no mocks

* chore: clean-up generalized tests

* chore: code format

* chore: code format

* chore: remove extracted tests

* test: add `create_invoice`: error test

* add: test for `create_invoice` with http 404

* test: extract `test_pay_invoice_ok`

* test: extract `test_pay_invoice_error_response`

* test: extract `test_pay_invoice_http_404`

* test: add "missing data"

* test: add `bad-json`

* test: add `no mocks` for `create_invoice`

* test: add `no mocks` for `pay_invoice`

* test: add `bad json` tests

* chore: re-order tests

* fix: response type

* test: add `missing data` test for `pay_imvoice`

* chore: re-order tests

* test: add `success` test for `get_invoice_status `

* feat: update test structure

* test: new status

* test: add more test

* fix: error handling

* chore: code clean-up

* test: add success test for `get_payment_status `

* test: add `pending` tests for `check_payment_status`

* chore: remove extracted tests

* test: add more tests

* test: add `no mocks` test

* fix: funding source loading

* refactor: extract `rest_wallet_fixtures_from_json` function

* chore: update comment

* feat: cover `cleanup` call also

* chore: code format

* refactor: start to extract data model

* refactor: extract mock class

* fix: typings

* refactor: improve typings

* chore: add some documentation

* chore: final clean-up

* chore: rename file

* chore: `poetry add --dev pytest_httpserver` (after rebase)
This commit is contained in:
Vlad Stan 2024-04-08 12:18:21 +03:00 committed by GitHub
parent b0a8e0d942
commit bfda0b62da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1705 additions and 92 deletions

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ __pycache__
*.egg
*.egg-info
.coverage
.coverage.*
.pytest_cache
.webassets-cache
htmlcov

View file

@ -18,6 +18,18 @@ class InvoiceResponse(NamedTuple):
payment_request: Optional[str] = None
error_message: Optional[str] = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class PaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded
@ -27,12 +39,28 @@ class PaymentResponse(NamedTuple):
preimage: Optional[str] = None
error_message: Optional[str] = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class PaymentStatus(NamedTuple):
paid: Optional[bool] = None
fee_msat: Optional[int] = None
preimage: Optional[str] = None
@property
def success(self) -> bool:
return self.paid is True
@property
def pending(self) -> bool:
return self.paid is not True

View file

@ -66,23 +66,28 @@ class CoreLightningRestWallet(Wallet):
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
r = await self.client.get(f"{self.url}/v1/channel/localremotebal", timeout=5)
r.raise_for_status()
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
return StatusResponse(
f"Failed to connect to {self.url}, got: '{error_message}...'", 0
try:
r = await self.client.get(
f"{self.url}/v1/channel/localremotebal", timeout=5
)
r.raise_for_status()
data = r.json()
data = r.json()
if len(data) == 0:
return StatusResponse("no data", 0)
if len(data) == 0:
return StatusResponse("no data", 0)
return StatusResponse(None, int(data.get("localBalance") * 1000))
if "error" in data:
return StatusResponse(f"""Server error: '{data["error"]}'""", 0)
if r.is_error or "localBalance" not in data:
return StatusResponse(f"Server error: '{r.text}'", 0)
return StatusResponse(None, int(data.get("localBalance") * 1000))
except json.JSONDecodeError:
return StatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return StatusResponse(f"Unable to connect to {self.url}.", 0)
async def create_invoice(
self,
@ -92,7 +97,7 @@ class CoreLightningRestWallet(Wallet):
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
label = f"lbl{random.random()}"
label = kwargs.get("label", f"lbl{random.random()}")
data: Dict = {
"amount": amount * 1000,
"description": memo,
@ -113,24 +118,41 @@ class CoreLightningRestWallet(Wallet):
if kwargs.get("preimage"):
data["preimage"] = kwargs["preimage"]
r = await self.client.post(
f"{self.url}/v1/invoice/genInvoice",
data=data,
)
try:
r = await self.client.post(
f"{self.url}/v1/invoice/genInvoice",
data=data,
)
r.raise_for_status()
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
data = r.json()
return InvoiceResponse(False, None, None, error_message)
if len(data) == 0:
return InvoiceResponse(False, None, None, "no data")
data = r.json()
assert "payment_hash" in data
assert "bolt11" in data
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
if "error" in data:
return InvoiceResponse(
False, None, None, f"""Server error: '{data["error"]}'"""
)
if r.is_error:
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
if "payment_hash" not in data or "bolt11" not in data:
return InvoiceResponse(
False, None, None, "Server error: 'missing required fields'"
)
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, f"Unable to connect to {self.url}."
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
@ -142,34 +164,53 @@ class CoreLightningRestWallet(Wallet):
error_message = "0 amount invoices are not allowed"
return PaymentResponse(False, None, None, None, error_message)
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
r = await self.client.post(
f"{self.url}/v1/pay",
data={
"invoice": bolt11,
"maxfeepercent": f"{fee_limit_percent:.11}",
"exemptfee": 0, # so fee_limit_percent is applied even on payments
# with fee < 5000 millisatoshi (which is default value of exemptfee)
},
timeout=None,
)
try:
r = await self.client.post(
f"{self.url}/v1/pay",
data={
"invoice": bolt11,
"maxfeepercent": f"{fee_limit_percent:.11}",
"exemptfee": 0, # so fee_limit_percent is applied even on payments
# with fee < 5000 millisatoshi (which is default value of exemptfee)
},
timeout=None,
)
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
return PaymentResponse(False, None, None, None, error_message)
r.raise_for_status()
data = r.json()
data = r.json()
if "error" in data:
return PaymentResponse(False, None, None, None, data["error"])
if r.is_error:
return PaymentResponse(False, None, None, None, r.text)
if (
"payment_hash" not in data
or "payment_preimage" not in data
or "msatoshi_sent" not in data
or "msatoshi" not in data
or "status" not in data
):
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
)
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
return PaymentResponse(
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
)
return PaymentResponse(
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
)
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.url}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(

View file

@ -87,15 +87,20 @@ class LndRestWallet(Wallet):
try:
r = await self.client.get("/v1/balance/channels")
r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError) as exc:
return StatusResponse(f"Unable to connect to {self.endpoint}. {exc}", 0)
try:
data = r.json()
if r.is_error:
raise Exception
except Exception:
return StatusResponse(r.text[:200], 0)
if len(data) == 0:
return StatusResponse("no data", 0)
if r.is_error or "balance" not in data:
return StatusResponse(f"Server error: '{r.text}'", 0)
except json.JSONDecodeError:
return StatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return StatusResponse(f"Unable to connect to {self.endpoint}.", 0)
return StatusResponse(None, int(data["balance"]) * 1000)
@ -123,22 +128,41 @@ class LndRestWallet(Wallet):
hashlib.sha256(unhashed_description).digest()
).decode("ascii")
r = await self.client.post(url="/v1/invoices", json=data)
try:
r = await self.client.post(url="/v1/invoices", json=data)
r.raise_for_status()
data = r.json()
if r.is_error:
error_message = r.text
try:
error_message = r.json()["error"]
except Exception:
pass
return InvoiceResponse(False, None, None, error_message)
if len(data) == 0:
return InvoiceResponse(False, None, None, "no data")
data = r.json()
payment_request = data["payment_request"]
payment_hash = base64.b64decode(data["r_hash"]).hex()
checking_id = payment_hash
if "error" in data:
return InvoiceResponse(
False, None, None, f"""Server error: '{data["error"]}'"""
)
return InvoiceResponse(True, checking_id, payment_request, None)
if r.is_error:
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
if "payment_request" not in data or "r_hash" not in data:
return InvoiceResponse(
False, None, None, "Server error: 'missing required fields'"
)
payment_request = data["payment_request"]
payment_hash = base64.b64decode(data["r_hash"]).hex()
checking_id = payment_hash
return InvoiceResponse(True, checking_id, payment_request, None)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
# set the fee limit for the payment
@ -154,29 +178,53 @@ class LndRestWallet(Wallet):
r.raise_for_status()
except Exception as exc:
logger.warning(f"LndRestWallet pay_invoice POST error: {exc}.")
return PaymentResponse(None, None, None, None, str(exc))
return PaymentResponse(
None, None, None, None, f"Unable to connect to {self.endpoint}."
)
data = r.json()
try:
data = r.json()
if data.get("payment_error"):
error_message = r.json().get("payment_error") or r.text
logger.warning(f"LndRestWallet pay_invoice payment_error: {error_message}.")
return PaymentResponse(False, None, None, None, error_message)
if data.get("payment_error"):
error_message = r.json().get("payment_error") or r.text
logger.warning(
f"LndRestWallet pay_invoice payment_error: {error_message}."
)
return PaymentResponse(False, None, None, None, error_message)
data = r.json()
checking_id = base64.b64decode(data["payment_hash"]).hex()
fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
if (
"payment_hash" not in data
or "payment_route" not in data
or "total_fees_msat" not in data["payment_route"]
or "payment_preimage" not in data
):
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
)
checking_id = base64.b64decode(data["payment_hash"]).hex()
fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(url=f"/v1/invoice/{checking_id}")
if r.is_error or not r.json().get("settled"):
# this must also work when checking_id is not a hex recognizable by lnd
# it will return an error and no "settled" attribute on the object
return PaymentPendingStatus()
try:
r.raise_for_status()
data = r.json()
if r.is_error or not data.get("settled"):
# this must also work when checking_id is not a hex recognizable by lnd
# it will return an error and no "settled" attribute on the object
return PaymentPendingStatus()
except Exception as e:
logger.error(f"Error getting invoice status: {e}")
return PaymentPendingStatus()
return PaymentSuccessStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:

55
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[package]]
name = "anyio"
@ -1302,6 +1302,16 @@ files = [
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
{file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
{file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
@ -1958,6 +1968,20 @@ pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
[[package]]
name = "pytest-httpserver"
version = "1.0.10"
description = "pytest-httpserver is a httpserver for pytest"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest_httpserver-1.0.10-py3-none-any.whl", hash = "sha256:d40e0cc3d61ed6e4d80f52a796926d557a7db62b17e43b3e258a78a3c34becb9"},
{file = "pytest_httpserver-1.0.10.tar.gz", hash = "sha256:77b9fbc2eb0a129cfbbacc8fe57e8cafe071d506489f31fe31e62f1b332d9905"},
]
[package.dependencies]
Werkzeug = ">=2.0.0"
[[package]]
name = "pytest-md"
version = "0.2.0"
@ -2068,6 +2092,7 @@ files = [
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@ -2075,8 +2100,15 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@ -2093,6 +2125,7 @@ files = [
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@ -2100,6 +2133,7 @@ files = [
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@ -2848,6 +2882,23 @@ files = [
{file = "websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016"},
]
[[package]]
name = "werkzeug"
version = "3.0.2"
description = "The comprehensive WSGI web application library."
optional = false
python-versions = ">=3.8"
files = [
{file = "werkzeug-3.0.2-py3-none-any.whl", hash = "sha256:3aac3f5da756f93030740bc235d3e09449efcf65f2f55e3602e1d851b8f48795"},
{file = "werkzeug-3.0.2.tar.gz", hash = "sha256:e39b645a6ac92822588e7b39a692e7828724ceae0b0d702ef96701f90e70128d"},
]
[package.dependencies]
MarkupSafe = ">=2.1.1"
[package.extras]
watchdog = ["watchdog (>=2.3)"]
[[package]]
name = "win32-setctime"
version = "1.1.0"
@ -2962,4 +3013,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10 | ^3.9"
content-hash = "a58655feabd699c4f4dd8ad67989f09dbc1385e6e62c46364bb3a7df4f254e8c"
content-hash = "4c11cc117beb703ebece5fac43adbabae76804f084c39ef90a67edcfb56795d7"

View file

@ -73,6 +73,7 @@ openai = "^1.12.0"
json5 = "^0.9.17"
asgi-lifespan = "^2.1.0"
pytest-md = "^0.2.0"
pytest-httpserver = "^1.0.10"
[build-system]
requires = ["poetry-core>=1.0.0"]

View file

@ -5,11 +5,12 @@ import random
import string
import time
from subprocess import PIPE, Popen, TimeoutExpired
from typing import Optional, Tuple
from typing import Dict, List, Optional, Tuple, Union
from loguru import logger
from psycopg2 import connect
from psycopg2.errors import InvalidCatalogName
from pydantic import BaseModel
from lnbits import core
from lnbits.db import DB_TYPE, POSTGRES, FromRowModel
@ -178,3 +179,110 @@ def clean_database(settings):
# TODO: do this once mock data is removed from test data folder
# os.remove(settings.lnbits_data_folder + "/database.sqlite3")
pass
def rest_wallet_fixtures_from_json(path) -> List["WalletTest"]:
with open(path) as f:
data = json.load(f)
funding_sources = data["funding_sources"]
tests: Dict[str, List[WalletTest]] = {
fs_name: [] for fs_name in funding_sources
}
for fn_name in data["functions"]:
fn = data["functions"][fn_name]
for test in fn["tests"]:
"""create an unit test for each funding source"""
for fs_name in funding_sources:
t = WalletTest(
**{
"funding_source": FundingSourceConfig(
**funding_sources[fs_name]
),
"function": fn_name,
**test,
"mocks": [],
}
)
if "mocks" in test:
test_mocks_names = test["mocks"][fs_name]
fs_mocks = fn["mocks"][fs_name]
for mock_name in fs_mocks:
for test_mock in test_mocks_names[mock_name]:
# different mocks that result in the same
# return value for the tested function
_mock = fs_mocks[mock_name] | test_mock
mock = Mock(**_mock)
unique_test = WalletTest(**t.dict())
unique_test.description = (
f"""{t.description}:{mock.description or ""}"""
)
unique_test.mocks = t.mocks + [mock]
tests[fs_name].append(unique_test)
else:
# add the test without mocks
tests[fs_name].append(t)
all_tests = sum([tests[fs_name] for fs_name in tests], [])
return all_tests
class FundingSourceConfig(BaseModel):
wallet_class: str
settings: dict
class FunctionMock(BaseModel):
uri: str
query_params: Optional[dict]
headers: dict
method: str
class TestMock(BaseModel):
description: Optional[str]
request_type: Optional[str]
request_body: Optional[dict]
response_type: str
response: Union[str, dict]
class Mock(FunctionMock, TestMock):
pass
class FunctionMocks(BaseModel):
mocks: Dict[str, FunctionMock]
class FunctionTest(BaseModel):
description: str
call_params: dict
expect: dict
mocks: Dict[str, List[Dict[str, TestMock]]]
class FunctionData(BaseModel):
"""Data required for testing this function"""
"Function level mocks that apply for all tests of this function"
mocks: List[FunctionMock] = []
"All the tests for this function"
tests: List[FunctionTest] = []
class WalletTest(BaseModel):
function: str
description: str
funding_source: FundingSourceConfig
call_params: Optional[dict] = {}
expect: Optional[dict]
expect_error: Optional[dict]
mocks: List[Mock] = []

1195
tests/wallets/fixtures.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,140 @@
import importlib
import json
from typing import Dict, Union
from urllib.parse import urlencode
import pytest
from pytest_httpserver import HTTPServer
from werkzeug.wrappers import Response
from lnbits.core.models import BaseWallet
from tests.helpers import (
FundingSourceConfig,
Mock,
WalletTest,
rest_wallet_fixtures_from_json,
)
wallets_module = importlib.import_module("lnbits.wallets")
# todo:
# - tests for extra fields
# - tests for paid_invoices_stream
# - test particular validations
# specify where the server should bind to
@pytest.fixture(scope="session")
def httpserver_listen_address():
return ("127.0.0.1", 8555)
def build_test_id(test: WalletTest):
return f"{test.funding_source}.{test.function}({test.description})"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_data",
rest_wallet_fixtures_from_json("tests/wallets/fixtures.json"),
ids=build_test_id,
)
async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest):
for mock in test_data.mocks:
_apply_mock(httpserver, mock)
wallet = _load_funding_source(test_data.funding_source)
await _check_assertions(wallet, test_data)
def _apply_mock(httpserver: HTTPServer, mock: Mock):
request_data: Dict[str, Union[str, dict]] = {}
request_type = getattr(mock.dict(), "request_type", None)
# request_type = mock.request_type <--- this des not work for whatever reason!!!
if request_type == "data":
assert isinstance(mock.response, dict), "request data must be JSON"
request_data["data"] = urlencode(mock.response)
elif request_type == "json":
request_data["json"] = mock.response
if mock.query_params:
request_data["query_string"] = mock.query_params
req = httpserver.expect_request(
uri=mock.uri,
headers=mock.headers,
method=mock.method,
**request_data, # type: ignore
)
server_response: Union[str, dict, Response] = mock.response
response_type = mock.response_type
if response_type == "response":
assert isinstance(server_response, dict), "server response must be JSON"
server_response = Response(**server_response)
elif response_type == "stream":
response_type = "response"
server_response = Response(iter(json.dumps(server_response).splitlines()))
respond_with = f"respond_with_{response_type}"
getattr(req, respond_with)(server_response)
async def _check_assertions(wallet, _test_data: WalletTest):
test_data = _test_data.dict()
tested_func = _test_data.function
call_params = _test_data.call_params
if "expect" in test_data:
await _assert_data(wallet, tested_func, call_params, _test_data.expect)
# if len(_test_data.mocks) == 0:
# # all calls should fail after this method is called
# await wallet.cleanup()
# # same behaviour expected is server canot be reached
# # or if the connection was closed
# await _assert_data(wallet, tested_func, call_params, _test_data.expect)
elif "expect_error" in test_data:
await _assert_error(wallet, tested_func, call_params, _test_data.expect_error)
else:
assert False, "Expected outcome not specified"
async def _assert_data(wallet, tested_func, call_params, expect):
resp = await getattr(wallet, tested_func)(**call_params)
for key in expect:
received = getattr(resp, key)
expected = expect[key]
assert (
getattr(resp, key) == expect[key]
), f"""Field "{key}". Received: "{received}". Expected: "{expected}"."""
async def _assert_error(wallet, tested_func, call_params, expect_error):
error_module = importlib.import_module(expect_error["module"])
error_class = getattr(error_module, expect_error["class"])
with pytest.raises(error_class) as e_info:
await getattr(wallet, tested_func)(**call_params)
assert e_info.match(expect_error["message"])
def _load_funding_source(funding_source: FundingSourceConfig) -> BaseWallet:
custom_settings = funding_source.settings | {"user_agent": "LNbits/Tests"}
original_settings = {}
settings = getattr(wallets_module, "settings")
for s in custom_settings:
original_settings[s] = getattr(settings, s)
setattr(settings, s, custom_settings[s])
fs_instance: BaseWallet = getattr(wallets_module, funding_source.wallet_class)()
# rollback settings (global variable)
for s in original_settings:
setattr(settings, s, original_settings[s])
return fs_instance