Harden transport and decode paths

This commit is contained in:
minionszyw
2026-04-11 21:47:08 +08:00
parent 68bd74e0c3
commit 16c237d5a0
27 changed files with 547 additions and 120 deletions
+36
View File
@@ -0,0 +1,36 @@
"""真实通达信服务器 smoke test。
默认跳过;设置 XMTDX_LIVE=1 后执行。
"""
from __future__ import annotations
import asyncio
import os
import pytest
from xmtdx import AsyncTdxClient, Market, TdxClient
_LIVE_ENABLED = os.getenv("XMTDX_LIVE") == "1"
_LIVE_HOST = os.getenv("XMTDX_HOST", "180.153.18.170")
pytestmark = pytest.mark.skipif(
not _LIVE_ENABLED,
reason="set XMTDX_LIVE=1 to run live integration tests",
)
def test_sync_live_smoke() -> None:
with TdxClient(_LIVE_HOST, timeout=5.0) as client:
assert client.get_security_count(Market.SH) > 0
assert client.get_security_count(Market.SZ) > 0
def test_async_live_smoke() -> None:
async def main() -> None:
async with AsyncTdxClient(_LIVE_HOST, timeout=5.0) as client:
assert await client.get_security_count(Market.SH) > 0
assert await client.get_security_count(Market.SZ) > 0
asyncio.run(main())
+139
View File
@@ -0,0 +1,139 @@
"""异步 transport 回归测试。"""
from __future__ import annotations
import asyncio
import struct
import time
from xmtdx import AsyncTdxClient, Market
from xmtdx.commands.security_count import GetSecurityCountCmd
from xmtdx.commands.setup import SETUP_COMMANDS
from xmtdx.exceptions import TdxConnectionError
def _pack_frame(body: bytes) -> bytes:
return struct.pack("<IIIHH", 0, 0, 0, len(body), len(body)) + body
def test_async_client_serializes_concurrent_calls() -> None:
request_len = len(GetSecurityCountCmd(Market.SH).build_request())
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
for setup_cmd in SETUP_COMMANDS:
await reader.readexactly(len(setup_cmd))
writer.write(_pack_frame(b""))
await writer.drain()
await reader.readexactly(request_len)
writer.write(_pack_frame(struct.pack("<H", 5)))
await writer.drain()
await reader.readexactly(request_len)
writer.write(_pack_frame(struct.pack("<H", 6)))
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
async def main() -> None:
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
try:
client = AsyncTdxClient("127.0.0.1", port=port, timeout=0.2)
await client.connect()
try:
sh_count, sz_count = await asyncio.gather(
client.get_security_count(Market.SH),
client.get_security_count(Market.SZ),
)
finally:
await client.close()
finally:
server.close()
await server.wait_closed()
assert sh_count == 5
assert sz_count == 6
asyncio.run(main())
def test_async_client_auto_reconnect() -> None:
request_len = len(GetSecurityCountCmd(Market.SH).build_request())
connection_ids: list[int] = []
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
connection_ids.append(len(connection_ids) + 1)
connection_id = connection_ids[-1]
try:
for setup_cmd in SETUP_COMMANDS:
await reader.readexactly(len(setup_cmd))
writer.write(_pack_frame(b""))
await writer.drain()
await reader.readexactly(request_len)
writer.write(_pack_frame(struct.pack("<H", 10 + connection_id)))
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
async def main() -> None:
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
try:
client = AsyncTdxClient("127.0.0.1", port=port, timeout=0.2)
first = await client.get_security_count(Market.SH)
second = await client.get_security_count(Market.SH)
await client.close()
finally:
server.close()
await server.wait_closed()
assert first == 11
assert second == 12
assert len(connection_ids) == 2
asyncio.run(main())
def test_async_client_request_timeout() -> None:
request_len = len(GetSecurityCountCmd(Market.SH).build_request())
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
for setup_cmd in SETUP_COMMANDS:
await reader.readexactly(len(setup_cmd))
writer.write(_pack_frame(b""))
await writer.drain()
await reader.readexactly(request_len)
await asyncio.sleep(1.0)
finally:
writer.close()
await writer.wait_closed()
async def main() -> None:
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
try:
client = AsyncTdxClient("127.0.0.1", port=port, timeout=0.05)
await client.connect()
t0 = time.monotonic()
try:
await client.get_security_count(Market.SH)
except TdxConnectionError as exc:
elapsed = time.monotonic() - t0
assert "超时" in str(exc) or "timed out" in str(exc)
assert elapsed < 0.3
else: # pragma: no cover - 防御性断言
raise AssertionError("expected timeout")
finally:
await client.close()
finally:
server.close()
await server.wait_closed()
asyncio.run(main())
-1
View File
@@ -1,6 +1,5 @@
"""get_price / put_price 单元测试,测试向量来自 pytdx 实际报文。"""
import pytest
from xmtdx.codec.price import get_price, put_price
+2 -2
View File
@@ -7,7 +7,6 @@ fixtures/ 目录下每个 .hex 文件是一次真实服务器响应的 body(
from __future__ import annotations
import pathlib
import pytest
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
@@ -75,7 +74,7 @@ def test_security_list_gbk_no_crash():
def test_security_bars_parse():
from xmtdx.commands.security_bars import GetSecurityBarsCmd
from xmtdx.models.enums import Market, KlineCategory
from xmtdx.models.enums import KlineCategory, Market
body = load_hex("security_bars")
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 5)
@@ -305,6 +304,7 @@ def test_company_info_category_parse():
assert len(cats) == 16
c0 = cats[0]
assert c0.name == "最新提示"
assert c0.filename == "600000.txt"
assert c0.start == 0
assert c0.length == 11426
+51
View File
@@ -0,0 +1,51 @@
"""坏包与解码异常回归测试。"""
from __future__ import annotations
from pathlib import Path
import pytest
from xmtdx.codec.frame import FrameHeader, decompress_body
from xmtdx.commands.company_info import GetCompanyInfoCategoryCmd
from xmtdx.commands.security_count import GetSecurityCountCmd
from xmtdx.commands.xdxr_info import GetXdxrInfoCmd
from xmtdx.exceptions import TdxDecodeError
from xmtdx.models.enums import Market
FIXTURES = Path(__file__).parent.parent / "fixtures"
def _load_hex(name: str) -> bytes:
return bytes.fromhex((FIXTURES / f"{name}.hex").read_text().strip())
def test_security_count_truncated_raises_tdxdecodeerror() -> None:
with pytest.raises(TdxDecodeError):
GetSecurityCountCmd(Market.SH).parse_response(b"")
def test_company_info_category_truncated_raises_tdxdecodeerror() -> None:
body = _load_hex("company_info_category")
cmd = GetCompanyInfoCategoryCmd(Market.SH, "600000")
with pytest.raises(TdxDecodeError):
cmd.parse_response(body[:-10])
def test_xdxr_info_truncated_raises_tdxdecodeerror() -> None:
body = _load_hex("xdxr_info")
cmd = GetXdxrInfoCmd(Market.SH, "600000")
with pytest.raises(TdxDecodeError):
cmd.parse_response(body[:-10])
def test_frame_bad_zlib_raises_tdxdecodeerror() -> None:
with pytest.raises(TdxDecodeError):
decompress_body(FrameHeader(0, 0, 0, 4, 8), b"xxxx")
def test_frame_unzipsize_mismatch_raises_tdxdecodeerror() -> None:
with pytest.raises(TdxDecodeError):
decompress_body(FrameHeader(0, 0, 0, 3, 4), b"abc")