Skip to content

Commit d08fbdf

Browse files
committed
Add tests and run them in CI
Add a comprehensive test suite for the LnBot SDK and enable running tests in CI. Introduces new tests (conftest.py, test_client.py, test_errors.py, test_resources.py, test_sse.py, test_types.py) that cover client construction, headers, HTTP methods, error mapping, resource endpoints, SSE streaming, and type conversion. Adds test helpers using httpx.MockTransport. Updates CI and release workflows to install the test extras and run pytest, and updates pyproject.toml to declare a "test" optional-dependency (pytest>=8) and pytest testpaths configuration.
1 parent 181bafb commit d08fbdf

File tree

9 files changed

+996
-2
lines changed

9 files changed

+996
-2
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ jobs:
1818
with:
1919
python-version: ${{ matrix.python-version }}
2020

21-
- run: pip install -e .
22-
- run: python -c "from lnbot import LnBot, AsyncLnBot; print('OK')"
21+
- run: pip install -e ".[test]"
22+
- run: pytest tests/ -v

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ jobs:
1919
with:
2020
python-version: "3.13"
2121

22+
- run: pip install -e ".[test]"
23+
- run: pytest tests/ -v
24+
2225
- run: pip install build
2326
- run: python -m build
2427

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,17 @@ classifiers = [
4646
]
4747
dependencies = ["httpx>=0.27"]
4848

49+
[project.optional-dependencies]
50+
test = ["pytest>=8"]
51+
4952
[project.urls]
5053
Homepage = "https://ln.bot"
5154
Documentation = "https://ln.bot/docs"
5255
Repository = "https://github.com/lnbotdev/python-sdk"
5356
Issues = "https://github.com/lnbotdev/python-sdk/issues"
5457

58+
[tool.pytest.ini_options]
59+
testpaths = ["tests"]
60+
5561
[tool.hatch.build.targets.wheel]
5662
packages = ["src/lnbot"]

tests/conftest.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Shared test helpers for the LnBot SDK test suite."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any
7+
8+
import httpx
9+
10+
from lnbot import LnBot
11+
12+
13+
class CapturedRequest:
14+
"""Stores details about the HTTP request that was made."""
15+
16+
def __init__(self) -> None:
17+
self.method: str = ""
18+
self.url: httpx.URL = httpx.URL("")
19+
self.headers: httpx.Headers = httpx.Headers()
20+
self.content: bytes = b""
21+
22+
@property
23+
def path(self) -> str:
24+
return self.url.raw_path.decode().split("?")[0]
25+
26+
@property
27+
def query(self) -> str:
28+
return self.url.query.decode() if self.url.query else ""
29+
30+
@property
31+
def json_body(self) -> Any:
32+
if self.content:
33+
return json.loads(self.content)
34+
return None
35+
36+
37+
def create_client(
38+
status: int = 200,
39+
json_body: Any = None,
40+
*,
41+
content_type: str = "application/json",
42+
) -> tuple[LnBot, CapturedRequest]:
43+
"""Create an LnBot client backed by a mock transport."""
44+
captured = CapturedRequest()
45+
46+
def handler(request: httpx.Request) -> httpx.Response:
47+
captured.method = request.method
48+
captured.url = request.url
49+
captured.headers = request.headers
50+
captured.content = request.content
51+
body = json.dumps(json_body).encode() if json_body is not None else b""
52+
return httpx.Response(status, content=body, headers={"content-type": content_type})
53+
54+
transport = httpx.MockTransport(handler)
55+
client = httpx.Client(transport=transport)
56+
return LnBot(api_key="key_test", http_client=client), captured
57+
58+
59+
def create_sse_client(sse_text: str) -> tuple[LnBot, CapturedRequest]:
60+
"""Create an LnBot client that returns SSE content for streaming endpoints."""
61+
captured = CapturedRequest()
62+
63+
def handler(request: httpx.Request) -> httpx.Response:
64+
captured.method = request.method
65+
captured.url = request.url
66+
captured.headers = request.headers
67+
captured.content = request.content
68+
return httpx.Response(
69+
200,
70+
content=sse_text.encode(),
71+
headers={"content-type": "text/event-stream"},
72+
)
73+
74+
transport = httpx.MockTransport(handler)
75+
client = httpx.Client(transport=transport)
76+
return LnBot(api_key="key_test", http_client=client), captured

tests/test_client.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Tests for the LnBot client core: construction, headers, HTTP methods, error mapping."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
import httpx
8+
9+
from lnbot import LnBot
10+
from lnbot.errors import (
11+
BadRequestError,
12+
ConflictError,
13+
ForbiddenError,
14+
LnBotError,
15+
NotFoundError,
16+
UnauthorizedError,
17+
)
18+
from conftest import create_client
19+
20+
21+
class TestClientConstruction:
22+
def test_default_base_url(self):
23+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
24+
client = httpx.Client(transport=transport)
25+
ln = LnBot(api_key="k", http_client=client)
26+
assert ln._base_url == "https://api.ln.bot"
27+
28+
def test_custom_base_url(self):
29+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
30+
client = httpx.Client(transport=transport)
31+
ln = LnBot(api_key="k", base_url="https://custom.api.com/", http_client=client)
32+
assert ln._base_url == "https://custom.api.com"
33+
34+
def test_trailing_slash_stripped(self):
35+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
36+
client = httpx.Client(transport=transport)
37+
ln = LnBot(api_key="k", base_url="https://api.example.com///", http_client=client)
38+
assert not ln._base_url.endswith("/")
39+
40+
def test_all_resources_initialized(self):
41+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
42+
client = httpx.Client(transport=transport)
43+
ln = LnBot(api_key="k", http_client=client)
44+
assert ln.wallets is not None
45+
assert ln.keys is not None
46+
assert ln.invoices is not None
47+
assert ln.payments is not None
48+
assert ln.addresses is not None
49+
assert ln.transactions is not None
50+
assert ln.webhooks is not None
51+
assert ln.events is not None
52+
assert ln.backup is not None
53+
assert ln.restore is not None
54+
assert ln.l402 is not None
55+
56+
def test_api_key_from_env(self, monkeypatch):
57+
monkeypatch.setenv("LNBOT_API_KEY", "env_key")
58+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
59+
client = httpx.Client(transport=transport)
60+
ln = LnBot(http_client=client)
61+
assert ln._api_key == "env_key"
62+
63+
def test_explicit_key_overrides_env(self, monkeypatch):
64+
monkeypatch.setenv("LNBOT_API_KEY", "env_key")
65+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
66+
client = httpx.Client(transport=transport)
67+
ln = LnBot(api_key="explicit_key", http_client=client)
68+
assert ln._api_key == "explicit_key"
69+
70+
def test_context_manager(self):
71+
transport = httpx.MockTransport(lambda r: httpx.Response(200))
72+
client = httpx.Client(transport=transport)
73+
with LnBot(api_key="k", http_client=client) as ln:
74+
assert ln is not None
75+
76+
77+
class TestHeaders:
78+
def test_sends_authorization(self):
79+
ln, cap = create_client(json_body={"walletId": "w", "name": "n", "balance": 0, "onHold": 0, "available": 0})
80+
ln.wallets.current()
81+
assert cap.headers["authorization"] == "Bearer key_test"
82+
83+
def test_omits_auth_when_no_key(self):
84+
from conftest import CapturedRequest
85+
import json
86+
87+
captured = CapturedRequest()
88+
89+
def handler(request: httpx.Request) -> httpx.Response:
90+
captured.method = request.method
91+
captured.url = request.url
92+
captured.headers = request.headers
93+
captured.content = request.content
94+
body = json.dumps({"walletId": "w", "name": "n", "balance": 0, "onHold": 0, "available": 0})
95+
return httpx.Response(200, content=body.encode(), headers={"content-type": "application/json"})
96+
97+
transport = httpx.MockTransport(handler)
98+
client = httpx.Client(transport=transport)
99+
ln = LnBot(http_client=client)
100+
ln.wallets.current()
101+
assert "authorization" not in captured.headers
102+
103+
def test_sends_accept_json(self):
104+
ln, cap = create_client(json_body={"walletId": "w", "name": "n", "balance": 0, "onHold": 0, "available": 0})
105+
ln.wallets.current()
106+
assert cap.headers["accept"] == "application/json"
107+
108+
def test_sends_user_agent(self):
109+
ln, cap = create_client(json_body={"walletId": "w", "name": "n", "balance": 0, "onHold": 0, "available": 0})
110+
ln.wallets.current()
111+
assert cap.headers["user-agent"].startswith("lnbot-python/")
112+
113+
def test_sends_content_type_for_post(self):
114+
ln, cap = create_client(json_body={"number": 1, "status": "pending", "amount": 100, "bolt11": "lnbc1..."})
115+
ln.invoices.create(amount=100)
116+
assert "application/json" in cap.headers["content-type"]
117+
118+
119+
class TestHTTPMethods:
120+
def test_get(self):
121+
ln, cap = create_client(json_body={"walletId": "w", "name": "n", "balance": 0, "onHold": 0, "available": 0})
122+
ln.wallets.current()
123+
assert cap.method == "GET"
124+
125+
def test_post(self):
126+
ln, cap = create_client(json_body={"number": 1, "status": "pending", "amount": 100, "bolt11": "lnbc1..."})
127+
ln.invoices.create(amount=100)
128+
assert cap.method == "POST"
129+
130+
def test_patch(self):
131+
ln, cap = create_client(json_body={"walletId": "w", "name": "Renamed", "balance": 0, "onHold": 0, "available": 0})
132+
ln.wallets.update(name="Renamed")
133+
assert cap.method == "PATCH"
134+
135+
def test_delete(self):
136+
ln, cap = create_client(status=200, json_body=None, content_type="text/plain")
137+
ln.webhooks.delete("wh_1")
138+
assert cap.method == "DELETE"
139+
140+
141+
class TestRequestBody:
142+
def test_serializes_json(self):
143+
ln, cap = create_client(json_body={"number": 1, "status": "pending", "amount": 100, "bolt11": "lnbc1..."})
144+
ln.invoices.create(amount=100, memo="test memo")
145+
body = cap.json_body
146+
assert body["amount"] == 100
147+
assert body["memo"] == "test memo"
148+
149+
def test_omits_none_values(self):
150+
ln, cap = create_client(json_body={"number": 1, "status": "pending", "amount": 100, "bolt11": "lnbc1..."})
151+
ln.invoices.create(amount=100)
152+
body = cap.json_body
153+
assert "memo" not in body
154+
assert "reference" not in body
155+
156+
def test_converts_to_camel_case(self):
157+
ln, cap = create_client(json_body={
158+
"number": 1, "status": "pending", "amount": 50, "maxFee": 10, "serviceFee": 0, "address": "user@ln.bot",
159+
})
160+
ln.payments.create(target="user@ln.bot", idempotency_key="idem_1")
161+
body = cap.json_body
162+
assert "idempotencyKey" in body
163+
assert "idempotency_key" not in body
164+
165+
166+
class TestResponseParsing:
167+
def test_parses_json_to_dataclass(self):
168+
ln, _ = create_client(json_body={"walletId": "wal_123", "name": "My Wallet", "balance": 1000, "onHold": 50, "available": 950})
169+
w = ln.wallets.current()
170+
assert w.wallet_id == "wal_123"
171+
assert w.name == "My Wallet"
172+
assert w.balance == 1000
173+
assert w.available == 950
174+
175+
def test_converts_camel_to_snake(self):
176+
ln, _ = create_client(json_body={"walletId": "wal_1", "name": "n", "balance": 0, "onHold": 50, "available": 0})
177+
w = ln.wallets.current()
178+
assert w.on_hold == 50
179+
180+
181+
class TestErrorMapping:
182+
@pytest.mark.parametrize(
183+
"status,exc_class",
184+
[
185+
(400, BadRequestError),
186+
(401, UnauthorizedError),
187+
(403, ForbiddenError),
188+
(404, NotFoundError),
189+
(409, ConflictError),
190+
],
191+
)
192+
def test_raises_typed_error(self, status, exc_class):
193+
ln, _ = create_client(status=status, json_body={"message": "test error"})
194+
with pytest.raises(exc_class) as exc_info:
195+
ln.wallets.current()
196+
assert exc_info.value.status == status
197+
assert isinstance(exc_info.value, LnBotError)
198+
199+
def test_unknown_status_raises_lnbot_error(self):
200+
ln, _ = create_client(status=500, json_body={"message": "server error"})
201+
with pytest.raises(LnBotError) as exc_info:
202+
ln.wallets.current()
203+
assert exc_info.value.status == 500
204+
205+
def test_extracts_message_from_error(self):
206+
ln, _ = create_client(status=400, json_body={"message": "invalid amount"})
207+
with pytest.raises(BadRequestError, match="invalid amount"):
208+
ln.wallets.current()
209+
210+
def test_extracts_error_field(self):
211+
ln, _ = create_client(status=400, json_body={"error": "bad input"})
212+
with pytest.raises(BadRequestError, match="bad input"):
213+
ln.wallets.current()

0 commit comments

Comments
 (0)