test_client.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. """Tests for Cq2Client + LauncherFacade using the REAL protocol (all mocked)."""
  2. import asyncio
  3. import httpx
  4. import pytest
  5. from cq2_sdk.client import Cq2Client, FlowResult, LauncherFacade
  6. from cq2_sdk.login import (
  7. Box996Login,
  8. BoxSession,
  9. CaptchaRequiredError,
  10. GameSession,
  11. ServerInfo,
  12. TodayOpenServer,
  13. )
  14. LOGIN_200 = {
  15. "code": 200,
  16. "msg": "登录成功",
  17. "data": {
  18. "token": "eyJfake.jwt",
  19. "userInfo": {"id": 1186115037, "account": "longwu988"},
  20. },
  21. "success": True,
  22. }
  23. TODAY_OPEN = {
  24. "code": 200,
  25. "msg": "ok",
  26. "data": [
  27. {"server_name": "霸业10服", "serverid": 14414736, "open_time": 1,
  28. "section": " 00:00", "state": 1, "main_serverid": "14414736"},
  29. {"server_name": "霸业12服", "serverid": 14414738, "open_time": 2,
  30. "section": " 10:00", "state": 1, "main_serverid": "14414738"},
  31. ],
  32. }
  33. def make_box() -> Box996Login:
  34. def handler(request: httpx.Request) -> httpx.Response:
  35. url = str(request.url)
  36. if "byPwd" in url:
  37. return httpx.Response(200, json=LOGIN_200)
  38. if "getTodayOpen" in url:
  39. return httpx.Response(200, json=TODAY_OPEN)
  40. if "EnterGameClient" in url:
  41. return httpx.Response(200, json={"code": 200, "msg": "", "data": {"download_info": {}}})
  42. return httpx.Response(404, json={"code": -1, "msg": "no fixture"})
  43. box = Box996Login()
  44. box._client = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=10)
  45. return box
  46. # ---- LauncherFacade ----
  47. @pytest.mark.asyncio
  48. async def test_launcher_get_today_servers():
  49. facade = LauncherFacade(make_box())
  50. servers = await facade.get_today_servers()
  51. assert len(servers) == 2
  52. assert servers[1].server_name == "霸业12服"
  53. @pytest.mark.asyncio
  54. async def test_launcher_enter_game():
  55. captured = {}
  56. def handler(request: httpx.Request) -> httpx.Response:
  57. if "EnterGameClient" in str(request.url):
  58. captured["body"] = request.content.decode()
  59. return httpx.Response(200, json={"code": 200, "msg": "", "data": {"ok": 1}})
  60. return httpx.Response(404, json={})
  61. box = Box996Login()
  62. box._client = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=10)
  63. facade = LauncherFacade(box)
  64. data = await facade.enter_game(14414738)
  65. assert data == {"ok": 1}
  66. assert "14414738" in captured["body"]
  67. @pytest.mark.asyncio
  68. async def test_run_until_enter_game_orchestrates_real_login(monkeypatch):
  69. events = []
  70. ready = []
  71. resolved = ServerInfo(
  72. server_id=14414736,
  73. main_server_id=14414736,
  74. name="霸业10服",
  75. host="game.example",
  76. port=8041,
  77. channel_id="110585",
  78. game_id=206923,
  79. )
  80. game_session = GameSession(
  81. char_id="role-1",
  82. char_name="Hero",
  83. session_id=149,
  84. user_id="user-1",
  85. world_ready=True,
  86. )
  87. async def fake_resolve(server_id, *, channel_id, game_id):
  88. events.append(("resolve", server_id, channel_id, game_id))
  89. return resolved
  90. class FakeTcp:
  91. def __init__(self, host, port):
  92. events.append(("tcp", host, port))
  93. async def connect(self):
  94. events.append("connect")
  95. async def start_heartbeat(self):
  96. events.append("heartbeat")
  97. async def close(self):
  98. events.append("close")
  99. class FakeGameLogin:
  100. def __init__(self, tcp):
  101. self.tcp = tcp
  102. async def login(self, session, server, **kwargs):
  103. events.append(("login", session.uid, server.server_id, kwargs))
  104. return game_session
  105. async def on_ready(tcp, server, result):
  106. ready.append((tcp, server, result))
  107. monkeypatch.setattr("cq2_sdk.client.fetch_server_info", fake_resolve)
  108. monkeypatch.setattr("cq2_sdk.client.TcpClient", FakeTcp)
  109. monkeypatch.setattr("cq2_sdk.client.GameLogin", FakeGameLogin)
  110. box = make_box()
  111. facade = LauncherFacade(
  112. box,
  113. BoxSession(uid="user-1", token="token", sdkid="", signkey=""),
  114. on_ready=on_ready,
  115. )
  116. result = await facade.run_until_enter_game(
  117. zone_index=0,
  118. channel_id="110585",
  119. char_name="Hero",
  120. job=1,
  121. sex=0,
  122. timeout=3.0,
  123. )
  124. await box.close()
  125. assert result is game_session
  126. assert events == [
  127. ("resolve", 14414736, "110585", 206923),
  128. ("tcp", "game.example", 8041),
  129. "connect",
  130. (
  131. "login",
  132. "user-1",
  133. 14414736,
  134. {"char_name": "Hero", "job": 1, "sex": 0, "timeout": 3.0},
  135. ),
  136. "heartbeat",
  137. ]
  138. assert ready == [(facade.tcp, resolved, game_session)]
  139. assert facade.game_session is game_session
  140. @pytest.mark.asyncio
  141. async def test_run_until_enter_game_requires_authenticated_session():
  142. box = make_box()
  143. facade = LauncherFacade(box)
  144. try:
  145. with pytest.raises(RuntimeError, match="authenticated BoxSession"):
  146. await facade.run_until_enter_game()
  147. finally:
  148. await box.close()
  149. @pytest.mark.asyncio
  150. @pytest.mark.parametrize(
  151. ("server_name", "zone_index", "expected_today_calls"),
  152. [("unsupported-game", 0, 0), ("玖扬复古176", 2, 1)],
  153. )
  154. async def test_invalid_launch_selection_has_no_enter_game_or_tcp_side_effects(
  155. monkeypatch, server_name, zone_index, expected_today_calls
  156. ):
  157. calls = []
  158. class Box:
  159. async def get_today_open(self, game_id):
  160. calls.append(("today", game_id))
  161. return [
  162. TodayOpenServer(
  163. server_name="One",
  164. serverid=1,
  165. open_time=1,
  166. section="00:00",
  167. state=1,
  168. main_serverid="1",
  169. )
  170. ]
  171. async def enter_game_client(self, **kwargs):
  172. calls.append(("enter", kwargs))
  173. def forbidden_tcp(*args, **kwargs):
  174. calls.append(("tcp", args, kwargs))
  175. raise AssertionError("TCP setup must not run")
  176. monkeypatch.setattr("cq2_sdk.client.TcpClient", forbidden_tcp)
  177. facade = LauncherFacade(
  178. Box(),
  179. BoxSession(uid="u", token="t", sdkid="", signkey=""),
  180. )
  181. with pytest.raises((ValueError, IndexError)):
  182. await facade.run_until_enter_game(
  183. server_name=server_name,
  184. zone_index=zone_index,
  185. )
  186. assert sum(call[0] == "today" for call in calls) == expected_today_calls
  187. assert not any(call[0] in {"enter", "tcp"} for call in calls)
  188. @pytest.mark.asyncio
  189. async def test_initial_tcp_connect_is_bounded_by_login_timeout(monkeypatch):
  190. server = ServerInfo(server_id=1, name="One", host="game.example", port=8041)
  191. connect_started = asyncio.Event()
  192. instances = []
  193. async def fake_resolve(*args, **kwargs):
  194. return server
  195. class StalledTcp:
  196. def __init__(self, host, port):
  197. self.cancelled = False
  198. self.closed = False
  199. instances.append(self)
  200. async def connect(self):
  201. connect_started.set()
  202. try:
  203. await asyncio.Event().wait()
  204. except asyncio.CancelledError:
  205. self.cancelled = True
  206. raise
  207. async def close(self):
  208. self.closed = True
  209. monkeypatch.setattr("cq2_sdk.client.fetch_server_info", fake_resolve)
  210. monkeypatch.setattr("cq2_sdk.client.TcpClient", StalledTcp)
  211. box = make_box()
  212. facade = LauncherFacade(
  213. box,
  214. BoxSession(uid="u", token="t", sdkid="", signkey=""),
  215. )
  216. loop = asyncio.get_running_loop()
  217. began = loop.time()
  218. try:
  219. with pytest.raises(TimeoutError):
  220. await asyncio.wait_for(
  221. facade.run_until_enter_game(timeout=0.02),
  222. timeout=0.2,
  223. )
  224. finally:
  225. await box.close()
  226. assert connect_started.is_set()
  227. assert loop.time() - began < 0.15
  228. assert instances[0].cancelled is True
  229. assert instances[0].closed is True
  230. @pytest.mark.asyncio
  231. async def test_second_launch_is_rejected_before_external_side_effects(monkeypatch):
  232. events = []
  233. server = ServerInfo(server_id=1, name="One", host="game.example", port=8041)
  234. game_session = GameSession(
  235. char_id="r1",
  236. char_name="Hero",
  237. session_id=149,
  238. user_id="u1",
  239. world_ready=True,
  240. )
  241. class Box:
  242. async def get_today_open(self, game_id):
  243. events.append("today")
  244. return [
  245. TodayOpenServer(
  246. server_name="One",
  247. serverid=1,
  248. open_time=1,
  249. section="00:00",
  250. state=1,
  251. main_serverid="1",
  252. )
  253. ]
  254. async def enter_game_client(self, **kwargs):
  255. events.append("enter")
  256. async def fake_resolve(*args, **kwargs):
  257. events.append("resolve")
  258. return server
  259. class FakeTcp:
  260. def __init__(self, host, port):
  261. events.append("tcp")
  262. async def connect(self):
  263. events.append("connect")
  264. async def start_heartbeat(self):
  265. events.append("heartbeat")
  266. async def close(self):
  267. events.append("close")
  268. class FakeGameLogin:
  269. def __init__(self, tcp):
  270. pass
  271. async def login(self, *args, **kwargs):
  272. events.append("login")
  273. return game_session
  274. monkeypatch.setattr("cq2_sdk.client.fetch_server_info", fake_resolve)
  275. monkeypatch.setattr("cq2_sdk.client.TcpClient", FakeTcp)
  276. monkeypatch.setattr("cq2_sdk.client.GameLogin", FakeGameLogin)
  277. facade = LauncherFacade(
  278. Box(),
  279. BoxSession(uid="u", token="t", sdkid="", signkey=""),
  280. )
  281. assert await facade.run_until_enter_game() is game_session
  282. after_first_launch = list(events)
  283. with pytest.raises(RuntimeError, match="already owns"):
  284. await facade.run_until_enter_game()
  285. assert events == after_first_launch
  286. @pytest.mark.asyncio
  287. async def test_run_until_enter_game_closes_tcp_when_ready_binding_fails(monkeypatch):
  288. events = []
  289. server = ServerInfo(
  290. server_id=1,
  291. name="One",
  292. host="game.example",
  293. port=8041,
  294. )
  295. result = GameSession(
  296. char_id="r1",
  297. char_name="Hero",
  298. session_id=1,
  299. user_id="u1",
  300. world_ready=True,
  301. )
  302. async def fake_resolve(*args, **kwargs):
  303. return server
  304. class FakeTcp:
  305. def __init__(self, host, port):
  306. pass
  307. async def connect(self):
  308. pass
  309. async def start_heartbeat(self):
  310. pass
  311. async def close(self):
  312. events.append("close")
  313. class FakeGameLogin:
  314. def __init__(self, tcp):
  315. pass
  316. async def login(self, *args, **kwargs):
  317. return result
  318. async def failing_ready(*args):
  319. raise RuntimeError("bind failed")
  320. monkeypatch.setattr("cq2_sdk.client.fetch_server_info", fake_resolve)
  321. monkeypatch.setattr("cq2_sdk.client.TcpClient", FakeTcp)
  322. monkeypatch.setattr("cq2_sdk.client.GameLogin", FakeGameLogin)
  323. box = make_box()
  324. facade = LauncherFacade(
  325. box,
  326. BoxSession(uid="u1", token="token", sdkid="", signkey=""),
  327. on_ready=failing_ready,
  328. )
  329. try:
  330. with pytest.raises(RuntimeError, match="bind failed"):
  331. await facade.run_until_enter_game()
  332. finally:
  333. await box.close()
  334. assert events == ["close"]
  335. assert facade.tcp is None
  336. # ---- Cq2Client ----
  337. @pytest.mark.asyncio
  338. async def test_client_password_login_full_flow(monkeypatch):
  339. box = make_box()
  340. monkeypatch.setattr(
  341. "cq2_sdk.client.Box996Login", lambda **kw: box
  342. )
  343. async with Cq2Client(account="longwu988", password="Longwu1988") as cli:
  344. assert cli.box_session is not None
  345. assert cli.box_session.uid == "1186115037"
  346. assert cli.launcher is not None
  347. assert cli.launcher.session is cli.box_session
  348. servers = await cli.launcher.get_today_servers()
  349. assert len(servers) == 2
  350. @pytest.mark.asyncio
  351. async def test_client_ready_callback_builds_gameplay_context():
  352. cli = Cq2Client(account="account", password="password")
  353. tcp = object()
  354. server = ServerInfo(
  355. server_id=7,
  356. name="Seven",
  357. host="game.example",
  358. port=8041,
  359. )
  360. game_session = GameSession(
  361. char_id="role-1",
  362. char_name="Hero",
  363. session_id=149,
  364. user_id="user-1",
  365. world_ready=True,
  366. )
  367. cli.box_session = BoxSession(
  368. uid="user-1", token="token", sdkid="", signkey=""
  369. )
  370. await cli._bind_game_session(tcp, server, game_session)
  371. assert cli.tcp is tcp
  372. assert cli.server_info is server
  373. assert cli.game_session is game_session
  374. assert cli.task is not None
  375. assert cli.task.ctx.tcp is tcp
  376. assert cli.task.ctx.box_session is cli.box_session
  377. assert cli.task.ctx.server is server
  378. assert cli.task.ctx.game_session is game_session
  379. assert cli.task.ctx.movement.tcp is tcp
  380. assert cli.task.ctx.combat.tcp is tcp
  381. assert cli.task.ctx.missions.tcp is tcp
  382. def test_client_requires_credentials():
  383. with pytest.raises(RuntimeError, match="password"):
  384. import asyncio
  385. asyncio.run(Cq2Client(account="x").__aenter__())
  386. def test_client_legacy_phone_param():
  387. cli = Cq2Client(phone="13800138000", password="pw")
  388. assert cli.account == "13800138000"
  389. def test_flow_result_defaults():
  390. r = FlowResult()
  391. assert r.completed_tasks == []
  392. assert r.elapsed == 0.0