Skip to content

Commit ea66cc7

Browse files
committed
test: add mock-based test suite and fix API exception propagation
- Add comprehensive mock tests using respx (no API key needed) - Test balance, create_task, get_task_result, join polling, solve convenience - Test context managers (sync and async) - Test API error handling, proxy serialization, configurable retries - Fix bug where CapmonsterAPIException was swallowed by generic except clause and re-wrapped as CapmonsterException, losing error details
1 parent 5399fbb commit ea66cc7

2 files changed

Lines changed: 219 additions & 0 deletions

File tree

src/capmonster_python/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,13 +249,17 @@ def __make_sync_request(self, url: str, payload: dict) -> Response:
249249
try:
250250
response = self.__sync_client.post(url, json=payload)
251251
return self.__check_response(response)
252+
except CapmonsterException:
253+
raise
252254
except Exception as e:
253255
raise CapmonsterException(-1, type(e).__name__, str(e))
254256

255257
async def __make_async_request(self, url: str, payload: dict) -> Response:
256258
try:
257259
post = await self.__async_client.post(url, json=payload)
258260
return self.__check_response(post)
261+
except CapmonsterException:
262+
raise
259263
except Exception as e:
260264
raise CapmonsterException(-1, type(e).__name__, str(e))
261265

tests/client_mock_test.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import pytest
2+
import respx
3+
from httpx import Response
4+
5+
from capmonster_python import (
6+
CapmonsterClient, RecaptchaV2Task, RecaptchaV3Task,
7+
CapmonsterAPIException, ProxyPayload, FunCaptchaTask
8+
)
9+
10+
BASE_URL = "https://api.capmonster.cloud"
11+
12+
13+
@respx.mock
14+
def test_get_balance():
15+
respx.post(f"{BASE_URL}/getBalance").mock(
16+
return_value=Response(200, json={"errorId": 0, "balance": 42.5})
17+
)
18+
client = CapmonsterClient("test_key")
19+
assert client.get_balance() == 42.5
20+
21+
22+
@respx.mock
23+
@pytest.mark.asyncio
24+
async def test_get_balance_async():
25+
respx.post(f"{BASE_URL}/getBalance").mock(
26+
return_value=Response(200, json={"errorId": 0, "balance": 10.0})
27+
)
28+
client = CapmonsterClient("test_key")
29+
assert await client.get_balance_async() == 10.0
30+
31+
32+
@respx.mock
33+
def test_create_task():
34+
respx.post(f"{BASE_URL}/createTask").mock(
35+
return_value=Response(200, json={"errorId": 0, "taskId": 123456})
36+
)
37+
client = CapmonsterClient("test_key")
38+
task = RecaptchaV2Task(websiteURL="https://example.com", websiteKey="key123")
39+
task_id = client.create_task(task)
40+
assert task_id == 123456
41+
42+
43+
@respx.mock
44+
@pytest.mark.asyncio
45+
async def test_create_task_async():
46+
respx.post(f"{BASE_URL}/createTask").mock(
47+
return_value=Response(200, json={"errorId": 0, "taskId": 789})
48+
)
49+
client = CapmonsterClient("test_key")
50+
task = RecaptchaV2Task(websiteURL="https://example.com", websiteKey="key123")
51+
task_id = await client.create_task_async(task)
52+
assert task_id == 789
53+
54+
55+
@respx.mock
56+
def test_get_task_result_ready():
57+
respx.post(f"{BASE_URL}/getTaskResult").mock(
58+
return_value=Response(200, json={
59+
"errorId": 0,
60+
"status": "ready",
61+
"solution": {"gRecaptchaResponse": "token_abc"}
62+
})
63+
)
64+
client = CapmonsterClient("test_key")
65+
result = client.get_task_result(123)
66+
assert result["gRecaptchaResponse"] == "token_abc"
67+
68+
69+
@respx.mock
70+
def test_get_task_result_processing():
71+
respx.post(f"{BASE_URL}/getTaskResult").mock(
72+
return_value=Response(200, json={
73+
"errorId": 0,
74+
"status": "processing"
75+
})
76+
)
77+
client = CapmonsterClient("test_key")
78+
result = client.get_task_result(123)
79+
assert result == {}
80+
81+
82+
@respx.mock
83+
def test_join_task_result_polls_then_succeeds():
84+
route = respx.post(f"{BASE_URL}/getTaskResult")
85+
route.side_effect = [
86+
Response(200, json={"errorId": 0, "status": "processing"}),
87+
Response(200, json={"errorId": 0, "status": "processing"}),
88+
Response(200, json={"errorId": 0, "status": "ready", "solution": {"token": "done"}}),
89+
]
90+
client = CapmonsterClient("test_key", retry_delay=0)
91+
result = client.join_task_result(123)
92+
assert result["token"] == "done"
93+
assert route.call_count == 3
94+
95+
96+
@respx.mock
97+
def test_join_task_result_exceeds_retries():
98+
respx.post(f"{BASE_URL}/getTaskResult").mock(
99+
return_value=Response(200, json={"errorId": 0, "status": "processing"})
100+
)
101+
client = CapmonsterClient("test_key", max_retries=2, retry_delay=0)
102+
with pytest.raises(CapmonsterAPIException) as exc_info:
103+
client.join_task_result(123)
104+
assert "ERROR_MAXIMUM_TIME_EXCEED" in str(exc_info.value)
105+
106+
107+
@respx.mock
108+
@pytest.mark.asyncio
109+
async def test_join_task_result_async_polls_then_succeeds():
110+
route = respx.post(f"{BASE_URL}/getTaskResult")
111+
route.side_effect = [
112+
Response(200, json={"errorId": 0, "status": "processing"}),
113+
Response(200, json={"errorId": 0, "status": "ready", "solution": {"token": "async_done"}}),
114+
]
115+
client = CapmonsterClient("test_key", retry_delay=0)
116+
result = await client.join_task_result_async(123)
117+
assert result["token"] == "async_done"
118+
119+
120+
@respx.mock
121+
def test_solve_convenience():
122+
respx.post(f"{BASE_URL}/createTask").mock(
123+
return_value=Response(200, json={"errorId": 0, "taskId": 999})
124+
)
125+
respx.post(f"{BASE_URL}/getTaskResult").mock(
126+
return_value=Response(200, json={
127+
"errorId": 0, "status": "ready",
128+
"solution": {"gRecaptchaResponse": "solved"}
129+
})
130+
)
131+
client = CapmonsterClient("test_key", retry_delay=0)
132+
task = RecaptchaV3Task(websiteURL="https://example.com", websiteKey="key", minScore=0.5)
133+
result = client.solve(task)
134+
assert result["gRecaptchaResponse"] == "solved"
135+
136+
137+
@respx.mock
138+
@pytest.mark.asyncio
139+
async def test_solve_async_convenience():
140+
respx.post(f"{BASE_URL}/createTask").mock(
141+
return_value=Response(200, json={"errorId": 0, "taskId": 888})
142+
)
143+
respx.post(f"{BASE_URL}/getTaskResult").mock(
144+
return_value=Response(200, json={
145+
"errorId": 0, "status": "ready",
146+
"solution": {"token": "async_solved"}
147+
})
148+
)
149+
client = CapmonsterClient("test_key", retry_delay=0)
150+
task = FunCaptchaTask(websiteURL="https://example.com", websitePublicKey="pk")
151+
result = await client.solve_async(task)
152+
assert result["token"] == "async_solved"
153+
154+
155+
@respx.mock
156+
def test_api_error_raises_exception():
157+
respx.post(f"{BASE_URL}/getBalance").mock(
158+
return_value=Response(200, json={
159+
"errorId": 1,
160+
"errorCode": "ERROR_KEY_DOES_NOT_EXIST",
161+
"errorDescription": "Account authorization key not found"
162+
})
163+
)
164+
client = CapmonsterClient("bad_key")
165+
with pytest.raises(CapmonsterAPIException) as exc_info:
166+
client.get_balance()
167+
assert exc_info.value.error_code == "ERROR_KEY_DOES_NOT_EXIST"
168+
169+
170+
@respx.mock
171+
def test_context_manager():
172+
respx.post(f"{BASE_URL}/getBalance").mock(
173+
return_value=Response(200, json={"errorId": 0, "balance": 5.0})
174+
)
175+
with CapmonsterClient("test_key") as client:
176+
balance = client.get_balance()
177+
assert balance == 5.0
178+
179+
180+
@respx.mock
181+
@pytest.mark.asyncio
182+
async def test_async_context_manager():
183+
respx.post(f"{BASE_URL}/getBalance").mock(
184+
return_value=Response(200, json={"errorId": 0, "balance": 3.0})
185+
)
186+
async with CapmonsterClient("test_key") as client:
187+
balance = await client.get_balance_async()
188+
assert balance == 3.0
189+
190+
191+
def test_proxy_payload_serialization():
192+
task = RecaptchaV2Task(
193+
websiteURL="https://example.com",
194+
websiteKey="key",
195+
proxy=ProxyPayload(
196+
proxyType="socks5",
197+
proxyAddress="192.168.1.1",
198+
proxyPort=1080,
199+
proxyLogin="user",
200+
proxyPassword="pass"
201+
)
202+
)
203+
result = task.to_request()
204+
assert result["proxyType"] == "socks5"
205+
assert result["proxyAddress"] == "192.168.1.1"
206+
assert result["proxyPort"] == 1080
207+
assert result["proxyLogin"] == "user"
208+
assert result["proxyPassword"] == "pass"
209+
assert "proxy" not in result
210+
211+
212+
def test_configurable_retry_params():
213+
client = CapmonsterClient("key", max_retries=5, retry_delay=0.5)
214+
assert client._CapmonsterClient__max_retries == 5
215+
assert client._CapmonsterClient__retry_delay == 0.5

0 commit comments

Comments
 (0)