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