botslab360 0.7.1__tar.gz → 0.7.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.
- {botslab360-0.7.1/src/botslab360.egg-info → botslab360-0.7.2}/PKG-INFO +1 -1
- {botslab360-0.7.1 → botslab360-0.7.2}/pyproject.toml +1 -1
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/client.py +90 -32
- {botslab360-0.7.1 → botslab360-0.7.2/src/botslab360.egg-info}/PKG-INFO +1 -1
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/SOURCES.txt +1 -0
- botslab360-0.7.2/tests/test_push_profiles.py +315 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/ATTRIBUTION.md +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/LICENSE +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/README.md +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/setup.cfg +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/__init__.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/auth.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/commands.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/exceptions.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/models.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/protocol.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/quc.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360/rooms.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/dependency_links.txt +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/requires.txt +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/src/botslab360.egg-info/top_level.txt +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_android_360_diagnostic.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_auth.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_commands.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_compare_android_quc.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_credentials_auth_example.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_devices.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_network_info.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_network_info_diagnostic.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_protocol.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_public_robot360_auth_diagnostic.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_public_room_api_diagnostic.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_quc.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_room_diagnostic.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_rooms.py +0 -0
- {botslab360-0.7.1 → botslab360-0.7.2}/tests/test_status.py +0 -0
|
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
|
-
|
|
9
|
-
from
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
10
|
+
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
10
12
|
from numbers import Integral
|
|
11
13
|
from typing import Any, TypeVar
|
|
12
14
|
from urllib.parse import quote_plus
|
|
@@ -71,6 +73,27 @@ ANDROID_DEVICE_MANUFACTURER = "Google"
|
|
|
71
73
|
ANDROID_DEVICE_MODEL = "sdk_gphone64_x86_64"
|
|
72
74
|
ANDROID_ROOM_USER_AGENT = "okhttp/4.10.0"
|
|
73
75
|
_ResultT = TypeVar("_ResultT")
|
|
76
|
+
_LOGGER = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class _PushProfile:
|
|
81
|
+
"""Transport settings for one initial push registration attempt."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
port: int
|
|
85
|
+
client_version: str
|
|
86
|
+
heartbeat_timeout: int
|
|
87
|
+
heartbeat_interval: float
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_LEGACY_PUSH_PROFILE = _PushProfile(
|
|
91
|
+
"legacy",
|
|
92
|
+
DEFAULT_PUSH_PORT,
|
|
93
|
+
DEFAULT_PUSH_CLIENT_VERSION,
|
|
94
|
+
DEFAULT_PUSH_HEARTBEAT_TIMEOUT,
|
|
95
|
+
DEFAULT_PUSH_HEARTBEAT_INTERVAL,
|
|
96
|
+
)
|
|
74
97
|
|
|
75
98
|
|
|
76
99
|
def _numeric_api_code(value: Any, *, status_code: int) -> int:
|
|
@@ -204,6 +227,7 @@ class Botslab360Client:
|
|
|
204
227
|
push_client_version: str,
|
|
205
228
|
push_heartbeat_timeout: int,
|
|
206
229
|
push_heartbeat_interval: float,
|
|
230
|
+
push_profile_name: str = "legacy",
|
|
207
231
|
) -> None:
|
|
208
232
|
self._request_timeout = timeout
|
|
209
233
|
self._http_client = (
|
|
@@ -219,6 +243,15 @@ class Botslab360Client:
|
|
|
219
243
|
self._push_client_version = push_client_version
|
|
220
244
|
self._push_heartbeat_timeout = push_heartbeat_timeout
|
|
221
245
|
self._push_heartbeat_interval = push_heartbeat_interval
|
|
246
|
+
self._push_profiles = (
|
|
247
|
+
_PushProfile(
|
|
248
|
+
push_profile_name,
|
|
249
|
+
push_port,
|
|
250
|
+
push_client_version,
|
|
251
|
+
push_heartbeat_timeout,
|
|
252
|
+
push_heartbeat_interval,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
222
255
|
self._auth = BotslabAuth(
|
|
223
256
|
self._http_client,
|
|
224
257
|
base_url=base_url,
|
|
@@ -282,7 +315,13 @@ class Botslab360Client:
|
|
|
282
315
|
push_client_version=push_client_version,
|
|
283
316
|
push_heartbeat_timeout=push_heartbeat_timeout,
|
|
284
317
|
push_heartbeat_interval=push_heartbeat_interval,
|
|
318
|
+
push_profile_name="android"
|
|
319
|
+
if backend is AuthBackend.ROBOT360
|
|
320
|
+
else "legacy",
|
|
285
321
|
)
|
|
322
|
+
# An explicit port override remains authoritative; do not redirect it.
|
|
323
|
+
if backend is AuthBackend.ROBOT360 and push_port is None:
|
|
324
|
+
client._push_profiles += (_LEGACY_PUSH_PROFILE,)
|
|
286
325
|
identity = device_identity or DeviceIdentity.generate()
|
|
287
326
|
client._credentials = None
|
|
288
327
|
client._account_fingerprint = None
|
|
@@ -528,6 +567,52 @@ class Botslab360Client:
|
|
|
528
567
|
)
|
|
529
568
|
)
|
|
530
569
|
|
|
570
|
+
@asynccontextmanager
|
|
571
|
+
async def _push_connection(self) -> AsyncIterator[PushClient]:
|
|
572
|
+
"""Try alternate profiles only before yielding an established channel."""
|
|
573
|
+
|
|
574
|
+
if self._session is None:
|
|
575
|
+
raise AuthenticationError(
|
|
576
|
+
"Authentication is required before opening push",
|
|
577
|
+
phase="authentication",
|
|
578
|
+
)
|
|
579
|
+
for index, profile in enumerate(self._push_profiles):
|
|
580
|
+
push = PushClient(
|
|
581
|
+
self._session.sid,
|
|
582
|
+
self._session.push_key,
|
|
583
|
+
host=self._push_host,
|
|
584
|
+
port=profile.port,
|
|
585
|
+
client_version=profile.client_version,
|
|
586
|
+
heartbeat_timeout=profile.heartbeat_timeout,
|
|
587
|
+
heartbeat_interval=profile.heartbeat_interval,
|
|
588
|
+
)
|
|
589
|
+
async with AsyncExitStack() as stack:
|
|
590
|
+
try:
|
|
591
|
+
await stack.enter_async_context(push)
|
|
592
|
+
except ApiError as error:
|
|
593
|
+
# Failed __aenter__ calls do not register an exit callback.
|
|
594
|
+
await push.close()
|
|
595
|
+
retry = (
|
|
596
|
+
not isinstance(
|
|
597
|
+
error, (AuthenticationError, InvalidSessionError)
|
|
598
|
+
)
|
|
599
|
+
and error.phase in {"transport", "timeout", "protocol"}
|
|
600
|
+
and index + 1 < len(self._push_profiles)
|
|
601
|
+
)
|
|
602
|
+
_LOGGER.debug(
|
|
603
|
+
"Push setup: profile=%s result=failed fallback=%s",
|
|
604
|
+
profile.name,
|
|
605
|
+
retry,
|
|
606
|
+
)
|
|
607
|
+
if not retry:
|
|
608
|
+
raise
|
|
609
|
+
continue
|
|
610
|
+
_LOGGER.debug("Push setup: profile=%s result=succeeded", profile.name)
|
|
611
|
+
# The operation runs outside the establishment exception handler:
|
|
612
|
+
# no profile retry can replay a command or a map trigger.
|
|
613
|
+
yield push
|
|
614
|
+
return
|
|
615
|
+
|
|
531
616
|
async def _get_status_once(
|
|
532
617
|
self,
|
|
533
618
|
*,
|
|
@@ -541,16 +626,7 @@ class Botslab360Client:
|
|
|
541
626
|
phase="authentication",
|
|
542
627
|
)
|
|
543
628
|
|
|
544
|
-
|
|
545
|
-
self._session.sid,
|
|
546
|
-
self._session.push_key,
|
|
547
|
-
host=self._push_host,
|
|
548
|
-
port=self._push_port,
|
|
549
|
-
client_version=self._push_client_version,
|
|
550
|
-
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
551
|
-
heartbeat_interval=self._push_heartbeat_interval,
|
|
552
|
-
)
|
|
553
|
-
async with push:
|
|
629
|
+
async with self._push_connection() as push:
|
|
554
630
|
await self._request_status(device_id=device_id, task_id=task_id)
|
|
555
631
|
return await push.wait_for_status(
|
|
556
632
|
device_id=device_id,
|
|
@@ -603,16 +679,7 @@ class Botslab360Client:
|
|
|
603
679
|
phase="authentication",
|
|
604
680
|
)
|
|
605
681
|
|
|
606
|
-
|
|
607
|
-
self._session.sid,
|
|
608
|
-
self._session.push_key,
|
|
609
|
-
host=self._push_host,
|
|
610
|
-
port=self._push_port,
|
|
611
|
-
client_version=self._push_client_version,
|
|
612
|
-
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
613
|
-
heartbeat_interval=self._push_heartbeat_interval,
|
|
614
|
-
)
|
|
615
|
-
async with push:
|
|
682
|
+
async with self._push_connection() as push:
|
|
616
683
|
await self._post_robot_request(
|
|
617
684
|
device_id=device_id,
|
|
618
685
|
info_type=NETWORK_INFO_TYPE,
|
|
@@ -695,16 +762,7 @@ class Botslab360Client:
|
|
|
695
762
|
"Authentication is required before requesting rooms",
|
|
696
763
|
phase="authentication",
|
|
697
764
|
)
|
|
698
|
-
|
|
699
|
-
self._session.sid,
|
|
700
|
-
self._session.push_key,
|
|
701
|
-
host=self._push_host,
|
|
702
|
-
port=self._push_port,
|
|
703
|
-
client_version=self._push_client_version,
|
|
704
|
-
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
705
|
-
heartbeat_interval=self._push_heartbeat_interval,
|
|
706
|
-
)
|
|
707
|
-
async with push:
|
|
765
|
+
async with self._push_connection() as push:
|
|
708
766
|
waiter = asyncio.create_task(
|
|
709
767
|
wait_for_room_map(
|
|
710
768
|
push,
|
|
@@ -27,6 +27,7 @@ tests/test_network_info_diagnostic.py
|
|
|
27
27
|
tests/test_protocol.py
|
|
28
28
|
tests/test_public_robot360_auth_diagnostic.py
|
|
29
29
|
tests/test_public_room_api_diagnostic.py
|
|
30
|
+
tests/test_push_profiles.py
|
|
30
31
|
tests/test_quc.py
|
|
31
32
|
tests/test_room_diagnostic.py
|
|
32
33
|
tests/test_rooms.py
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Offline coverage of push establishment fallback without command replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from urllib.parse import parse_qs
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import botslab360.client as client_module
|
|
14
|
+
from botslab360 import (
|
|
15
|
+
ApiError,
|
|
16
|
+
AuthBackend,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
Botslab360Client,
|
|
19
|
+
DeviceIdentity,
|
|
20
|
+
InvalidSessionError,
|
|
21
|
+
NetworkInfo,
|
|
22
|
+
QihooCredentials,
|
|
23
|
+
RobotStatus,
|
|
24
|
+
SmartSession,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
QID = "1234567890"
|
|
28
|
+
Q = f"u=360H{QID}&n=synthetic&m=not-a-real-token"
|
|
29
|
+
T = "s=synthetic-session&t=1700000000&v=2.0"
|
|
30
|
+
SID = "synthetic-private-sid"
|
|
31
|
+
PUSH_KEY = "synthetic-private-push-key"
|
|
32
|
+
DEVICE_ID = "synthetic-private-device"
|
|
33
|
+
IDENTITY = DeviceIdentity(
|
|
34
|
+
mid="0123456789abcdef0123456789abcdef",
|
|
35
|
+
android_id="0123456789abcdef",
|
|
36
|
+
m2="00000000-0000-4000-8000-000000000001",
|
|
37
|
+
)
|
|
38
|
+
ANDROID = (80, "1.21", 20, 15.0)
|
|
39
|
+
LEGACY = (443, "1.7", 30, 25.0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_client(http, backend=AuthBackend.ROBOT360, **kwargs):
|
|
43
|
+
if backend is None:
|
|
44
|
+
client = Botslab360Client(Q, T, http_client=http, **kwargs)
|
|
45
|
+
else:
|
|
46
|
+
client = Botslab360Client.from_credentials(
|
|
47
|
+
"synthetic@example.invalid",
|
|
48
|
+
"synthetic-private-password",
|
|
49
|
+
backend=backend,
|
|
50
|
+
device_identity=IDENTITY,
|
|
51
|
+
http_client=http,
|
|
52
|
+
**kwargs,
|
|
53
|
+
)
|
|
54
|
+
client._set_credentials(QihooCredentials(q=Q, t=T, qid=QID))
|
|
55
|
+
client._session = SmartSession(qid=QID, sid=SID, push_key=PUSH_KEY)
|
|
56
|
+
return client
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
("operation", "scenario"),
|
|
61
|
+
[
|
|
62
|
+
(operation, scenario)
|
|
63
|
+
for operation in ("rooms", "status", "network", "clean_rooms")
|
|
64
|
+
for scenario in ("android", "fallback", "both", "post_push", "post_http")
|
|
65
|
+
]
|
|
66
|
+
+ [("clean_rooms", "cleaning_http"), ("rooms", "map_parse")],
|
|
67
|
+
)
|
|
68
|
+
def test_profile_fallback_never_replays_operations(
|
|
69
|
+
monkeypatch, caplog, operation, scenario
|
|
70
|
+
):
|
|
71
|
+
async def run():
|
|
72
|
+
attempts = []
|
|
73
|
+
posts = []
|
|
74
|
+
issued = asyncio.Event()
|
|
75
|
+
connected = False
|
|
76
|
+
final_error = ApiError("Push connection closed", phase="transport")
|
|
77
|
+
|
|
78
|
+
class FakePush:
|
|
79
|
+
def __init__(self, sid, push_key, **kwargs):
|
|
80
|
+
assert (sid, push_key) == (SID, PUSH_KEY)
|
|
81
|
+
self.profile = tuple(
|
|
82
|
+
kwargs[key]
|
|
83
|
+
for key in (
|
|
84
|
+
"port",
|
|
85
|
+
"client_version",
|
|
86
|
+
"heartbeat_timeout",
|
|
87
|
+
"heartbeat_interval",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
self.closed = False
|
|
91
|
+
attempts.append(self)
|
|
92
|
+
|
|
93
|
+
async def __aenter__(self):
|
|
94
|
+
nonlocal connected
|
|
95
|
+
if scenario == "both" or (
|
|
96
|
+
scenario == "fallback" and len(attempts) == 1
|
|
97
|
+
):
|
|
98
|
+
raise final_error
|
|
99
|
+
connected = True
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def close(self):
|
|
103
|
+
nonlocal connected
|
|
104
|
+
self.closed = True
|
|
105
|
+
connected = False
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, *args):
|
|
108
|
+
await self.close()
|
|
109
|
+
|
|
110
|
+
async def receive(self):
|
|
111
|
+
await issued.wait()
|
|
112
|
+
if scenario == "post_push":
|
|
113
|
+
raise final_error
|
|
114
|
+
|
|
115
|
+
async def read_event(self):
|
|
116
|
+
await self.receive()
|
|
117
|
+
if scenario == "map_parse":
|
|
118
|
+
return {"sn": DEVICE_ID, "data": {"infoType": "20002", "data": []}}
|
|
119
|
+
return {
|
|
120
|
+
"sn": DEVICE_ID,
|
|
121
|
+
"data": json.dumps(
|
|
122
|
+
{
|
|
123
|
+
"infoType": "20002",
|
|
124
|
+
"data": {
|
|
125
|
+
"mapId": 1,
|
|
126
|
+
"cleanId": "synthetic-private-clean-id",
|
|
127
|
+
"smartArea": {
|
|
128
|
+
"activeIds": [],
|
|
129
|
+
"value": [{"id": 1, "name": "Um9vbQ=="}],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async def wait_for_network_info(self, **kwargs):
|
|
137
|
+
await self.receive()
|
|
138
|
+
return NetworkInfo("192.0.2.1", None, None, None)
|
|
139
|
+
|
|
140
|
+
async def wait_for_status(self, **kwargs):
|
|
141
|
+
await self.receive()
|
|
142
|
+
return RobotStatus(DEVICE_ID, True, 80, "idle", False, "auto", 0, 0, 0)
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(client_module, "PushClient", FakePush)
|
|
145
|
+
|
|
146
|
+
async def handler(request):
|
|
147
|
+
if request.url.path == "/clean/cmd/send":
|
|
148
|
+
assert connected
|
|
149
|
+
posts.append((request.url.path, parse_qs(request.content.decode())))
|
|
150
|
+
issued.set()
|
|
151
|
+
if scenario == "post_http" or (
|
|
152
|
+
scenario == "cleaning_http"
|
|
153
|
+
and request.url.path == "/clean/record/setAreaAndCleaning"
|
|
154
|
+
):
|
|
155
|
+
return httpx.Response(200, json={"errno": 999})
|
|
156
|
+
return httpx.Response(200, json={"errno": 0})
|
|
157
|
+
|
|
158
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
|
159
|
+
client = make_client(http)
|
|
160
|
+
method = {
|
|
161
|
+
"rooms": client.get_rooms,
|
|
162
|
+
"status": client.get_status,
|
|
163
|
+
"network": client.get_network_info,
|
|
164
|
+
"clean_rooms": lambda device: client.clean_rooms(device, [1]),
|
|
165
|
+
}[operation]
|
|
166
|
+
if scenario in {
|
|
167
|
+
"both",
|
|
168
|
+
"post_push",
|
|
169
|
+
"post_http",
|
|
170
|
+
"cleaning_http",
|
|
171
|
+
"map_parse",
|
|
172
|
+
}:
|
|
173
|
+
with pytest.raises(ApiError) as raised:
|
|
174
|
+
await method(DEVICE_ID)
|
|
175
|
+
if scenario in {"both", "post_push"}:
|
|
176
|
+
assert raised.value is final_error
|
|
177
|
+
else:
|
|
178
|
+
result = await method(DEVICE_ID)
|
|
179
|
+
if operation == "rooms":
|
|
180
|
+
assert [(room.id, room.name) for room in result] == [(1, "Room")]
|
|
181
|
+
elif operation == "status":
|
|
182
|
+
assert result.state == "idle"
|
|
183
|
+
elif operation == "network":
|
|
184
|
+
assert result.station_ip == "192.0.2.1"
|
|
185
|
+
await client.close()
|
|
186
|
+
assert not http.is_closed
|
|
187
|
+
|
|
188
|
+
assert [push.profile for push in attempts] == (
|
|
189
|
+
[ANDROID, LEGACY] if scenario in {"fallback", "both"} else [ANDROID]
|
|
190
|
+
)
|
|
191
|
+
assert all(push.closed for push in attempts)
|
|
192
|
+
assert not connected
|
|
193
|
+
expected_posts = 0 if scenario == "both" else 1
|
|
194
|
+
if operation == "clean_rooms" and scenario in {
|
|
195
|
+
"android",
|
|
196
|
+
"fallback",
|
|
197
|
+
"cleaning_http",
|
|
198
|
+
}:
|
|
199
|
+
expected_posts = 2
|
|
200
|
+
assert posts[1][0] == "/clean/record/setAreaAndCleaning"
|
|
201
|
+
assert json.loads(posts[1][1]["areaSetting"][0])["activeIds"] == [1]
|
|
202
|
+
assert len(posts) == expected_posts
|
|
203
|
+
if posts:
|
|
204
|
+
assert posts[0][0] == "/clean/cmd/send"
|
|
205
|
+
assert posts[0][1]["infoType"] == [
|
|
206
|
+
{"status": "20001", "network": "21019"}.get(operation, "30000")
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
caplog.set_level(logging.DEBUG, logger="botslab360.client")
|
|
210
|
+
asyncio.run(run())
|
|
211
|
+
diagnostics = "\n".join(
|
|
212
|
+
record.getMessage()
|
|
213
|
+
for record in caplog.records
|
|
214
|
+
if record.name == "botslab360.client"
|
|
215
|
+
)
|
|
216
|
+
assert "profile=android" in diagnostics
|
|
217
|
+
if scenario == "fallback":
|
|
218
|
+
assert "profile=legacy result=succeeded" in diagnostics
|
|
219
|
+
for secret in (
|
|
220
|
+
Q,
|
|
221
|
+
T,
|
|
222
|
+
QID,
|
|
223
|
+
SID,
|
|
224
|
+
PUSH_KEY,
|
|
225
|
+
DEVICE_ID,
|
|
226
|
+
"synthetic-private-password",
|
|
227
|
+
"synthetic-private-clean-id",
|
|
228
|
+
"Push connection closed",
|
|
229
|
+
):
|
|
230
|
+
assert secret not in diagnostics
|
|
231
|
+
assert all(record.exc_info is None for record in caplog.records)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.mark.parametrize("phase", ["transport", "timeout", "protocol"])
|
|
235
|
+
def test_initial_registration_phases_allow_one_fallback(monkeypatch, phase):
|
|
236
|
+
_check_setup_error(
|
|
237
|
+
monkeypatch, ApiError("synthetic-error", phase=phase), expected_ports=[80, 443]
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.parametrize(
|
|
242
|
+
"error",
|
|
243
|
+
[
|
|
244
|
+
ApiError("synthetic-error", phase="json"),
|
|
245
|
+
ApiError("synthetic-error", phase="api"),
|
|
246
|
+
ApiError("synthetic-error", phase="response-validation"),
|
|
247
|
+
AuthenticationError("synthetic-error", phase="transport"),
|
|
248
|
+
InvalidSessionError("synthetic-error", phase="transport"),
|
|
249
|
+
],
|
|
250
|
+
)
|
|
251
|
+
def test_non_transport_or_session_errors_do_not_switch_profiles(monkeypatch, error):
|
|
252
|
+
_check_setup_error(monkeypatch, error, expected_ports=[80])
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.parametrize(
|
|
256
|
+
"backend,override,expected_ports",
|
|
257
|
+
[
|
|
258
|
+
(None, None, [443]),
|
|
259
|
+
(AuthBackend.BOTSLAB, None, [443]),
|
|
260
|
+
(AuthBackend.ROBOT360, 1234, [1234]),
|
|
261
|
+
],
|
|
262
|
+
)
|
|
263
|
+
def test_default_backends_and_explicit_port_remain_authoritative(
|
|
264
|
+
monkeypatch, backend, override, expected_ports
|
|
265
|
+
):
|
|
266
|
+
_check_setup_error(
|
|
267
|
+
monkeypatch,
|
|
268
|
+
ApiError("synthetic-error", phase="transport"),
|
|
269
|
+
expected_ports=expected_ports,
|
|
270
|
+
backend=backend,
|
|
271
|
+
override=override,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _check_setup_error(
|
|
276
|
+
monkeypatch, error, *, expected_ports, backend=AuthBackend.ROBOT360, override=None
|
|
277
|
+
):
|
|
278
|
+
async def run():
|
|
279
|
+
attempts = []
|
|
280
|
+
closed = []
|
|
281
|
+
|
|
282
|
+
class FailingPush:
|
|
283
|
+
def __init__(self, sid, push_key, **kwargs):
|
|
284
|
+
self.port = kwargs["port"]
|
|
285
|
+
attempts.append(self.port)
|
|
286
|
+
|
|
287
|
+
async def __aenter__(self):
|
|
288
|
+
raise error
|
|
289
|
+
|
|
290
|
+
async def close(self):
|
|
291
|
+
closed.append(self.port)
|
|
292
|
+
|
|
293
|
+
async def __aexit__(self, *args):
|
|
294
|
+
pytest.fail("A failed entry must not register an exit callback")
|
|
295
|
+
|
|
296
|
+
monkeypatch.setattr(client_module, "PushClient", FailingPush)
|
|
297
|
+
async with httpx.AsyncClient(
|
|
298
|
+
transport=httpx.MockTransport(
|
|
299
|
+
lambda request: pytest.fail("No HTTP expected")
|
|
300
|
+
)
|
|
301
|
+
) as http:
|
|
302
|
+
client = make_client(
|
|
303
|
+
http,
|
|
304
|
+
backend,
|
|
305
|
+
**({"push_port": override} if override is not None else {}),
|
|
306
|
+
)
|
|
307
|
+
with pytest.raises(ApiError) as raised:
|
|
308
|
+
async with client._push_connection():
|
|
309
|
+
pytest.fail("No established channel expected")
|
|
310
|
+
assert raised.value is error
|
|
311
|
+
await client.close()
|
|
312
|
+
assert attempts == expected_ports
|
|
313
|
+
assert closed == expected_ports
|
|
314
|
+
|
|
315
|
+
asyncio.run(run())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|