python3-capsolver 1.2.1__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {python3_capsolver-1.2.1/src/python3_capsolver.egg-info → python3_capsolver-1.2.2}/PKG-INFO +3 -1
  2. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/pyproject.toml +2 -0
  3. python3_capsolver-1.2.2/src/python3_capsolver/__version__.py +1 -0
  4. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/aio_captcha_instrument.py +8 -3
  5. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/sio_captcha_instrument.py +8 -3
  6. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2/src/python3_capsolver.egg-info}/PKG-INFO +3 -1
  7. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver.egg-info/SOURCES.txt +2 -0
  8. python3_capsolver-1.2.2/tests/test_aio_captcha_instrument.py +186 -0
  9. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_instrument.py +16 -2
  10. python3_capsolver-1.2.2/tests/test_sio_captcha_instrument.py +185 -0
  11. python3_capsolver-1.2.1/src/python3_capsolver/__version__.py +0 -1
  12. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/LICENSE +0 -0
  13. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/MANIFEST.in +0 -0
  14. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/README.md +0 -0
  15. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/setup.cfg +0 -0
  16. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/__init__.py +0 -0
  17. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/aws_waf.py +0 -0
  18. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/cloudflare.py +0 -0
  19. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/control.py +0 -0
  20. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/__init__.py +0 -0
  21. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/base.py +0 -0
  22. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/captcha_instrument.py +0 -0
  23. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/const.py +0 -0
  24. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/context_instr.py +0 -0
  25. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/enum.py +0 -0
  26. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/serializer.py +0 -0
  27. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/core/utils.py +0 -0
  28. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/datadome_slider.py +0 -0
  29. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/friendly_captcha.py +0 -0
  30. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/gee_test.py +0 -0
  31. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/image_to_text.py +0 -0
  32. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/mt_captcha.py +0 -0
  33. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/recaptcha.py +0 -0
  34. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/vision_engine.py +0 -0
  35. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver/yandex.py +0 -0
  36. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver.egg-info/dependency_links.txt +0 -0
  37. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver.egg-info/requires.txt +0 -0
  38. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/src/python3_capsolver.egg-info/top_level.txt +0 -0
  39. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_aws_waf.py +0 -0
  40. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_cloudflare.py +0 -0
  41. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_control.py +0 -0
  42. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_core.py +0 -0
  43. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_datadome.py +0 -0
  44. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_friendly.py +0 -0
  45. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_gee_test.py +0 -0
  46. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_image_to_text.py +0 -0
  47. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_mt_captcha.py +0 -0
  48. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_recaptcha.py +0 -0
  49. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_vision_engine.py +0 -0
  50. {python3_capsolver-1.2.1 → python3_capsolver-1.2.2}/tests/test_yandex.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-capsolver
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Python 3.8+ Capsolver library with AIO module.
5
5
  Author-email: AndreiDrang <python-captcha@pm.me>
6
6
  License: MIT License
@@ -21,6 +21,8 @@ Classifier: Programming Language :: Python :: 3.9
21
21
  Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
24
26
  Classifier: Framework :: AsyncIO
25
27
  Classifier: Operating System :: Unix
26
28
  Classifier: Operating System :: Microsoft :: Windows
@@ -78,6 +78,8 @@ classifiers = [
78
78
  "Programming Language :: Python :: 3.10",
79
79
  "Programming Language :: Python :: 3.11",
80
80
  "Programming Language :: Python :: 3.12",
81
+ "Programming Language :: Python :: 3.13",
82
+ "Programming Language :: Python :: 3.14",
81
83
  "Framework :: AsyncIO",
82
84
  "Operating System :: Unix",
83
85
  "Operating System :: Microsoft :: Windows",
@@ -0,0 +1 @@
1
+ __version__ = "1.2.2"
@@ -1,6 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import logging
3
- from typing import Optional
5
+ from typing import TYPE_CHECKING
4
6
  from urllib import parse
5
7
 
6
8
  import aiohttp
@@ -11,6 +13,9 @@ from .utils import attempts_generator
11
13
  from .serializer import CaptchaResponseSer
12
14
  from .captcha_instrument import CaptchaInstrumentBase
13
15
 
16
+ if TYPE_CHECKING:
17
+ from .base import CaptchaParams
18
+
14
19
  __all__ = ("AIOCaptchaInstrument",)
15
20
 
16
21
 
@@ -19,7 +24,7 @@ class AIOCaptchaInstrument(CaptchaInstrumentBase):
19
24
  Instrument for working with async captcha
20
25
  """
21
26
 
22
- def __init__(self, captcha_params: "CaptchaParams"):
27
+ def __init__(self, captcha_params: CaptchaParams):
23
28
  super().__init__()
24
29
  self.captcha_params = captcha_params
25
30
  self.created_task_data = CaptchaResponseSer
@@ -99,7 +104,7 @@ class AIOCaptchaInstrument(CaptchaInstrumentBase):
99
104
 
100
105
  @staticmethod
101
106
  async def send_post_request(
102
- payload: Optional[dict] = None,
107
+ payload: dict | None = None,
103
108
  url_postfix: EndpointPostfixEnm = EndpointPostfixEnm.GET_BALANCE,
104
109
  ) -> dict:
105
110
  """
@@ -1,6 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  import time
2
4
  import logging
3
- from typing import Optional
5
+ from typing import TYPE_CHECKING
4
6
  from urllib import parse
5
7
 
6
8
  import requests
@@ -12,6 +14,9 @@ from .utils import attempts_generator
12
14
  from .serializer import CaptchaResponseSer
13
15
  from .captcha_instrument import CaptchaInstrumentBase
14
16
 
17
+ if TYPE_CHECKING:
18
+ from .base import CaptchaParams
19
+
15
20
  __all__ = ("SIOCaptchaInstrument",)
16
21
 
17
22
 
@@ -20,7 +25,7 @@ class SIOCaptchaInstrument(CaptchaInstrumentBase):
20
25
  Instrument for working with sync captcha
21
26
  """
22
27
 
23
- def __init__(self, captcha_params: "CaptchaParams"):
28
+ def __init__(self, captcha_params: CaptchaParams):
24
29
  super().__init__()
25
30
  self.captcha_params = captcha_params
26
31
  self.created_task_data = CaptchaResponseSer
@@ -106,7 +111,7 @@ class SIOCaptchaInstrument(CaptchaInstrumentBase):
106
111
 
107
112
  @staticmethod
108
113
  def send_post_request(
109
- payload: Optional[dict] = None,
114
+ payload: dict | None = None,
110
115
  session: requests.Session = requests.Session(),
111
116
  url_postfix: EndpointPostfixEnm = EndpointPostfixEnm.GET_BALANCE,
112
117
  ) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-capsolver
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Python 3.8+ Capsolver library with AIO module.
5
5
  Author-email: AndreiDrang <python-captcha@pm.me>
6
6
  License: MIT License
@@ -21,6 +21,8 @@ Classifier: Programming Language :: Python :: 3.9
21
21
  Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
24
26
  Classifier: Framework :: AsyncIO
25
27
  Classifier: Operating System :: Unix
26
28
  Classifier: Operating System :: Microsoft :: Windows
@@ -30,6 +30,7 @@ src/python3_capsolver/core/enum.py
30
30
  src/python3_capsolver/core/serializer.py
31
31
  src/python3_capsolver/core/sio_captcha_instrument.py
32
32
  src/python3_capsolver/core/utils.py
33
+ tests/test_aio_captcha_instrument.py
33
34
  tests/test_aws_waf.py
34
35
  tests/test_cloudflare.py
35
36
  tests/test_control.py
@@ -41,5 +42,6 @@ tests/test_image_to_text.py
41
42
  tests/test_instrument.py
42
43
  tests/test_mt_captcha.py
43
44
  tests/test_recaptcha.py
45
+ tests/test_sio_captcha_instrument.py
44
46
  tests/test_vision_engine.py
45
47
  tests/test_yandex.py
@@ -0,0 +1,186 @@
1
+ from unittest.mock import AsyncMock, MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ from tests.conftest import BaseTest
6
+ from python3_capsolver.core.base import CaptchaParams
7
+ from python3_capsolver.core.enum import CaptchaTypeEnm, EndpointPostfixEnm
8
+ from python3_capsolver.core.const import REQUEST_URL
9
+ from python3_capsolver.core.aio_captcha_instrument import AIOCaptchaInstrument
10
+
11
+ API_KEY_STUB = "unit-test-api-key-000000000000000000000000"
12
+ CREATE_TASK_URL = "https://unit.test/createTask"
13
+ GET_TASK_RESULT_URL = "https://unit.test/getTaskResult"
14
+
15
+
16
+ def create_captcha_params() -> CaptchaParams:
17
+ """
18
+ Build params pointing to a fake API host with zero sleep between polls:
19
+ no real request can leave the mocked `aiohttp.ClientSession.post` boundary
20
+ """
21
+ return CaptchaParams(
22
+ api_key=API_KEY_STUB,
23
+ captcha_type=CaptchaTypeEnm.Control,
24
+ sleep_time=0,
25
+ request_url="https://unit.test/",
26
+ )
27
+
28
+
29
+ def aio_response(payload=None, status: int = 200, reason: str = "unit test failure") -> MagicMock:
30
+ """
31
+ Build fake response for the `aiohttp` boundary, usable as async context manager.
32
+
33
+ For non-parsable bodies (``payload is None``) any ``json()`` call raises -
34
+ an error status must never be parsed as a success body
35
+ """
36
+ response = MagicMock()
37
+ response.status = status
38
+ response.reason = reason
39
+ if payload is None:
40
+ response.json = AsyncMock(side_effect=AssertionError("body of an error response must not be parsed"))
41
+ else:
42
+ response.json = AsyncMock(return_value=payload)
43
+ response.__aenter__.return_value = response
44
+ return response
45
+
46
+
47
+ class TestAIOCaptchaInstrument(BaseTest):
48
+ """
49
+ Mocked unit tests for AIOCaptchaInstrument
50
+ """
51
+
52
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
53
+ async def test_returns_created_result_without_polling_when_task_is_ready(self, mock_post):
54
+ mock_post.return_value = aio_response(
55
+ {"errorId": 0, "taskId": "task-ready-1", "status": "ready", "solution": {"text": "wg6x"}}
56
+ )
57
+
58
+ result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
59
+
60
+ assert result["errorId"] == 0
61
+ assert result["status"] == "ready"
62
+ assert result["taskId"] == "task-ready-1"
63
+ assert result["solution"] == {"text": "wg6x"}
64
+ # task was solved at creation - no `getTaskResult` polling must follow
65
+ mock_post.assert_called_once()
66
+ assert mock_post.call_args.args[0] == CREATE_TASK_URL
67
+ assert mock_post.call_args.kwargs["json"]["task"] == {"type": "Control"}
68
+ assert mock_post.call_args.kwargs["json"]["clientKey"] == API_KEY_STUB
69
+
70
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
71
+ async def test_polls_get_task_result_until_ready(self, mock_post):
72
+ mock_post.side_effect = [
73
+ aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
74
+ aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
75
+ aio_response({"errorId": 0, "taskId": "task-42", "status": "ready", "solution": {"text": "ab12"}}),
76
+ ]
77
+
78
+ result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
79
+
80
+ assert result["errorId"] == 0
81
+ assert result["status"] == "ready"
82
+ assert result["solution"] == {"text": "ab12"}
83
+ assert [call.args[0] for call in mock_post.call_args_list] == [
84
+ CREATE_TASK_URL,
85
+ GET_TASK_RESULT_URL,
86
+ GET_TASK_RESULT_URL,
87
+ ]
88
+ # created taskId must be propagated to every polling request
89
+ assert mock_post.call_args_list[1].kwargs["json"]["taskId"] == "task-42"
90
+ assert mock_post.call_args_list[2].kwargs["json"]["taskId"] == "task-42"
91
+
92
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
93
+ async def test_returns_failed_result_when_polling_reports_failure(self, mock_post):
94
+ mock_post.side_effect = [
95
+ aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
96
+ aio_response({"errorId": 1, "errorCode": "ERROR_TASK_NOT_FOUND", "status": "failed"}),
97
+ ]
98
+
99
+ result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
100
+
101
+ assert result["errorId"] == 1
102
+ assert result["status"] == "failed"
103
+ assert result["errorCode"] == "ERROR_TASK_NOT_FOUND"
104
+ assert len(mock_post.call_args_list) == 2
105
+
106
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
107
+ async def test_marks_result_failed_when_task_creation_is_rejected(self, mock_post):
108
+ mock_post.return_value = aio_response(
109
+ {"errorId": 1, "errorCode": "ERROR_KEY_DOES_NOT_EXIST", "errorDescription": "Client key is invalid"}
110
+ )
111
+
112
+ result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
113
+
114
+ assert result["errorId"] == 1
115
+ assert result["status"] == "failed"
116
+ assert result["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
117
+ assert result["errorDescription"] == "Client key is invalid"
118
+ # rejected creation must not trigger result polling
119
+ mock_post.assert_called_once()
120
+
121
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
122
+ async def test_parses_error_payload_when_status_code_is_valid_error_status(self, mock_post):
123
+ mock_post.return_value = aio_response({"errorId": 1, "errorCode": "ERROR_WRONG_USERKEY"}, status=400)
124
+
125
+ result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
126
+
127
+ assert result["status"] == "failed"
128
+ assert result["errorCode"] == "ERROR_WRONG_USERKEY"
129
+
130
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
131
+ async def test_returns_unsolvable_default_when_server_never_finishes(self, mock_post):
132
+ mock_post.side_effect = [aio_response({"errorId": 0, "taskId": "task-silent", "status": "processing"})] * 16
133
+
134
+ result = await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
135
+
136
+ assert result["errorId"] == 1
137
+ assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE"
138
+ assert result["errorDescription"] == "Captcha not recognized"
139
+ assert result["status"] == "failed"
140
+ assert result["taskId"] == "task-silent"
141
+ # 1 `createTask` + 15 polling attempts, then the instrument gives up
142
+ assert len(mock_post.call_args_list) == 16
143
+
144
+ @pytest.mark.parametrize("bad_status", [404, 500])
145
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
146
+ async def test_raises_when_create_task_returns_invalid_status(self, mock_post, bad_status):
147
+ mock_post.return_value = aio_response(status=bad_status, reason="Internal Server Error")
148
+
149
+ with pytest.raises(ValueError):
150
+ await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
151
+
152
+ mock_post.assert_called_once()
153
+
154
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
155
+ async def test_raises_when_get_task_result_returns_invalid_status(self, mock_post):
156
+ mock_post.side_effect = [
157
+ aio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
158
+ aio_response(status=503, reason="Service Unavailable"),
159
+ ]
160
+
161
+ with pytest.raises(ValueError):
162
+ await AIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
163
+
164
+ assert len(mock_post.call_args_list) == 2
165
+
166
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
167
+ async def test_send_post_request_returns_decoded_payload(self, mock_post):
168
+ mock_post.return_value = aio_response({"errorId": 0, "balance": 107.7})
169
+
170
+ result = await AIOCaptchaInstrument.send_post_request(
171
+ payload={"clientKey": API_KEY_STUB},
172
+ url_postfix=EndpointPostfixEnm.GET_BALANCE,
173
+ )
174
+
175
+ assert result == {"errorId": 0, "balance": 107.7}
176
+ assert mock_post.call_args.args[0] == f"{REQUEST_URL}/{EndpointPostfixEnm.GET_BALANCE.value}"
177
+ assert mock_post.call_args.kwargs["json"] == {"clientKey": API_KEY_STUB}
178
+
179
+ @patch("python3_capsolver.core.aio_captcha_instrument.aiohttp.ClientSession.post")
180
+ async def test_send_post_request_raises_for_error_status(self, mock_post):
181
+ mock_post.return_value = aio_response(status=403)
182
+
183
+ with pytest.raises(ValueError):
184
+ await AIOCaptchaInstrument.send_post_request(payload={"clientKey": API_KEY_STUB})
185
+
186
+ mock_post.assert_called_once()
@@ -1,3 +1,5 @@
1
+ from unittest.mock import MagicMock, patch
2
+
1
3
  import pytest
2
4
 
3
5
  from tests.conftest import BaseTest
@@ -11,8 +13,8 @@ class TestFileInstrument(BaseTest):
11
13
  """
12
14
 
13
15
  def test_file_processing_exist(self):
14
- assert "file_processing" in FileInstrument.__dict__.keys()
15
- assert "aio_file_processing" in FileInstrument.__dict__.keys()
16
+ assert "file_processing" in FileInstrument.__dict__
17
+ assert "aio_file_processing" in FileInstrument.__dict__
16
18
 
17
19
  def test_captcha_base64(self):
18
20
  assert self.read_image_as_str() == FileInstrument().file_processing(captcha_base64=self.read_image())
@@ -55,3 +57,15 @@ class TestFileInstrument(BaseTest):
55
57
  async def test_aio_file_processing_err(self):
56
58
  with pytest.raises(ValueError):
57
59
  await FileInstrument().aio_file_processing()
60
+
61
+ @patch("python3_capsolver.core.captcha_instrument.aiohttp.ClientSession.get")
62
+ async def test_aio_captcha_link_download_err(self, mock_get):
63
+ mock_response = MagicMock()
64
+ mock_response.status = 404
65
+ mock_response.__aenter__.return_value = mock_response
66
+ mock_get.return_value = mock_response
67
+
68
+ result = await FileInstrument().aio_file_processing(captcha_link=self.image_captcha_url_example)
69
+
70
+ # unreachable link returns empty content instead of raising
71
+ assert result == ""
@@ -0,0 +1,185 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ from tests.conftest import BaseTest
6
+ from python3_capsolver.core.base import CaptchaParams
7
+ from python3_capsolver.core.enum import CaptchaTypeEnm, EndpointPostfixEnm
8
+ from python3_capsolver.core.const import REQUEST_URL
9
+ from python3_capsolver.core.sio_captcha_instrument import SIOCaptchaInstrument
10
+
11
+ API_KEY_STUB = "unit-test-api-key-000000000000000000000000"
12
+ CREATE_TASK_URL = "https://unit.test/createTask"
13
+ GET_TASK_RESULT_URL = "https://unit.test/getTaskResult"
14
+
15
+
16
+ def create_captcha_params() -> CaptchaParams:
17
+ """
18
+ Build params pointing to a fake API host with zero sleep between polls:
19
+ no real request can leave the mocked `Session.post` boundary
20
+ """
21
+ return CaptchaParams(
22
+ api_key=API_KEY_STUB,
23
+ captcha_type=CaptchaTypeEnm.Control,
24
+ sleep_time=0,
25
+ request_url="https://unit.test/",
26
+ )
27
+
28
+
29
+ def sio_response(payload=None, status_code: int = 200) -> MagicMock:
30
+ """
31
+ Build fake response for the `requests` boundary.
32
+
33
+ For non-parsable bodies (``payload is None``) any ``json()`` call raises -
34
+ an error status must never be parsed as a success body
35
+ """
36
+ response = MagicMock()
37
+ response.status_code = status_code
38
+ if payload is None:
39
+ response.json.side_effect = AssertionError("body of an error response must not be parsed")
40
+ else:
41
+ response.json.return_value = payload
42
+ response.raise_for_status.return_value = None
43
+ return response
44
+
45
+
46
+ class TestSIOCaptchaInstrument(BaseTest):
47
+ """
48
+ Mocked unit tests for SIOCaptchaInstrument
49
+ """
50
+
51
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
52
+ def test_returns_created_result_without_polling_when_task_is_ready(self, mock_post):
53
+ mock_post.return_value = sio_response(
54
+ {"errorId": 0, "taskId": "task-ready-1", "status": "ready", "solution": {"text": "wg6x"}}
55
+ )
56
+
57
+ result = SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
58
+
59
+ assert result["errorId"] == 0
60
+ assert result["status"] == "ready"
61
+ assert result["taskId"] == "task-ready-1"
62
+ assert result["solution"] == {"text": "wg6x"}
63
+ # task was solved at creation - no `getTaskResult` polling must follow
64
+ mock_post.assert_called_once()
65
+ assert mock_post.call_args.args[0] == CREATE_TASK_URL
66
+ assert mock_post.call_args.kwargs["json"]["task"] == {"type": "Control"}
67
+ assert mock_post.call_args.kwargs["json"]["clientKey"] == API_KEY_STUB
68
+
69
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
70
+ def test_polls_get_task_result_until_ready(self, mock_post):
71
+ mock_post.side_effect = [
72
+ sio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
73
+ sio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
74
+ sio_response({"errorId": 0, "taskId": "task-42", "status": "ready", "solution": {"text": "ab12"}}),
75
+ ]
76
+
77
+ result = SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
78
+
79
+ assert result["errorId"] == 0
80
+ assert result["status"] == "ready"
81
+ assert result["solution"] == {"text": "ab12"}
82
+ assert [call.args[0] for call in mock_post.call_args_list] == [
83
+ CREATE_TASK_URL,
84
+ GET_TASK_RESULT_URL,
85
+ GET_TASK_RESULT_URL,
86
+ ]
87
+ # created taskId must be propagated to every polling request
88
+ assert mock_post.call_args_list[1].kwargs["json"]["taskId"] == "task-42"
89
+ assert mock_post.call_args_list[2].kwargs["json"]["taskId"] == "task-42"
90
+
91
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
92
+ def test_returns_failed_result_when_polling_reports_failure(self, mock_post):
93
+ mock_post.side_effect = [
94
+ sio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
95
+ sio_response({"errorId": 1, "errorCode": "ERROR_TASK_NOT_FOUND", "status": "failed"}),
96
+ ]
97
+
98
+ result = SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
99
+
100
+ assert result["errorId"] == 1
101
+ assert result["status"] == "failed"
102
+ assert result["errorCode"] == "ERROR_TASK_NOT_FOUND"
103
+ assert len(mock_post.call_args_list) == 2
104
+
105
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
106
+ def test_marks_result_failed_when_task_creation_is_rejected(self, mock_post):
107
+ mock_post.return_value = sio_response(
108
+ {"errorId": 1, "errorCode": "ERROR_KEY_DOES_NOT_EXIST", "errorDescription": "Client key is invalid"}
109
+ )
110
+
111
+ result = SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
112
+
113
+ assert result["errorId"] == 1
114
+ assert result["status"] == "failed"
115
+ assert result["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
116
+ assert result["errorDescription"] == "Client key is invalid"
117
+ # rejected creation must not trigger result polling
118
+ mock_post.assert_called_once()
119
+
120
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
121
+ def test_parses_error_payload_when_status_code_is_valid_error_status(self, mock_post):
122
+ mock_post.return_value = sio_response({"errorId": 1, "errorCode": "ERROR_WRONG_USERKEY"}, status_code=400)
123
+
124
+ result = SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
125
+
126
+ assert result["status"] == "failed"
127
+ assert result["errorCode"] == "ERROR_WRONG_USERKEY"
128
+
129
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
130
+ def test_returns_unsolvable_default_when_server_never_finishes(self, mock_post):
131
+ mock_post.side_effect = [sio_response({"errorId": 0, "taskId": "task-silent", "status": "processing"})] * 16
132
+
133
+ result = SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
134
+
135
+ assert result["errorId"] == 1
136
+ assert result["errorCode"] == "ERROR_CAPTCHA_UNSOLVABLE"
137
+ assert result["errorDescription"] == "Captcha not recognized"
138
+ assert result["status"] == "failed"
139
+ assert result["taskId"] == "task-silent"
140
+ # 1 `createTask` + 15 polling attempts, then the instrument gives up
141
+ assert len(mock_post.call_args_list) == 16
142
+
143
+ @pytest.mark.parametrize("bad_status", [404, 500])
144
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
145
+ def test_raises_when_create_task_returns_invalid_status(self, mock_post, bad_status):
146
+ mock_post.return_value = sio_response(status_code=bad_status)
147
+
148
+ with pytest.raises(ValueError):
149
+ SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
150
+
151
+ mock_post.assert_called_once()
152
+
153
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
154
+ def test_raises_when_get_task_result_returns_invalid_status(self, mock_post):
155
+ mock_post.side_effect = [
156
+ sio_response({"errorId": 0, "taskId": "task-42", "status": "processing"}),
157
+ sio_response(status_code=503),
158
+ ]
159
+
160
+ with pytest.raises(ValueError):
161
+ SIOCaptchaInstrument(captcha_params=create_captcha_params()).processing_captcha()
162
+
163
+ assert len(mock_post.call_args_list) == 2
164
+
165
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
166
+ def test_send_post_request_returns_decoded_payload(self, mock_post):
167
+ mock_post.return_value = sio_response({"errorId": 0, "balance": 107.7})
168
+
169
+ result = SIOCaptchaInstrument.send_post_request(
170
+ payload={"clientKey": API_KEY_STUB},
171
+ url_postfix=EndpointPostfixEnm.GET_BALANCE,
172
+ )
173
+
174
+ assert result == {"errorId": 0, "balance": 107.7}
175
+ assert mock_post.call_args.args[0] == f"{REQUEST_URL}/{EndpointPostfixEnm.GET_BALANCE.value}"
176
+ assert mock_post.call_args.kwargs["json"] == {"clientKey": API_KEY_STUB}
177
+
178
+ @patch("python3_capsolver.core.sio_captcha_instrument.requests.Session.post")
179
+ def test_send_post_request_raises_for_error_status(self, mock_post):
180
+ mock_post.return_value = sio_response(status_code=403)
181
+
182
+ with pytest.raises(ValueError):
183
+ SIOCaptchaInstrument.send_post_request(payload={"clientKey": API_KEY_STUB})
184
+
185
+ mock_post.assert_called_once()
@@ -1 +0,0 @@
1
- __version__ = "1.2.1"