Files
MoviePilot/tests/test_feishu_longconn.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

406 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
飞书事件长连接协议测试:pbbp2 帧编解码、事件应答、分包合并、握手失败分类与真实 WebSocket 收发。
"""
import base64
import json
import threading
import time
from types import SimpleNamespace
import pytest
import websocket
from websockets.sync.server import serve
from app.adapters.network import feishu as longconn
from app.adapters.network.feishu import FatalConnectionError, FeishuLongConnection, Frame
# 以下字节由 lark-oapi 1.6 的 pbbp2_pb2.Frame.SerializeToString() 生成,作为协议对照样本。
SDK_PING_FRAME = bytes.fromhex("08001000180720002a0c0a0474797065120470696e67")
SDK_EVENT_FRAME = bytes.fromhex(
"08031009180720012a0d0a047479706512056576656e742a100a0a6d6573736167655f696412026d31"
"2a080a0373756d1201312a080a037365711201302a0e0a0874726163655f69641202743142107b2273"
"6368656d61223a22322e30227d4a014c"
)
def _event_frame(payload: bytes, message_type: str = "event", **headers: str) -> Frame:
"""
构造服务端下发的 DATA 帧。
"""
frame_headers = [("type", message_type), ("message_id", headers.pop("message_id", "m1"))]
frame_headers += [("sum", headers.pop("sum", "1")), ("seq", headers.pop("seq", "0"))]
frame_headers += [("trace_id", "t1")]
return Frame(seq_id=3, log_id=9, service=7, method=1, headers=frame_headers, payload=payload)
def _connection(on_event=None) -> FeishuLongConnection:
return FeishuLongConnection("cli_app", "secret", on_event=on_event or (lambda payload: None))
def test_ping_frame_matches_sdk_encoding():
"""
心跳帧与 SDK 编码逐字节一致,required 字段即使为 0 也写出。
"""
ping = Frame(service=7, method=0, headers=[("type", "ping")])
assert ping.encode() == SDK_PING_FRAME
def test_event_frame_round_trips_sdk_bytes():
"""
解析 SDK 编码的事件帧后再编码,字节不变,optional 字段按原样保留。
"""
frame = Frame.decode(SDK_EVENT_FRAME)
assert (frame.seq_id, frame.log_id, frame.service, frame.method) == (3, 9, 7, 1)
assert frame.header("type") == "event"
assert frame.header("message_id") == "m1"
assert frame.payload == b'{"schema":"2.0"}'
assert frame.log_id_new == "L"
assert frame.payload_encoding is None
assert frame.encode() == SDK_EVENT_FRAME
def test_negative_int32_field_round_trips():
"""
int32 负值按 10 字节 varint 编码,解码后恢复符号。
"""
frame = Frame(service=-5, method=1)
assert Frame.decode(frame.encode()).service == -5
def test_truncated_frame_raises():
"""
截断的帧不会被当作合法数据。
"""
with pytest.raises(ValueError):
# 去掉最后一字节后,LogIDNew 声明的长度超出剩余数据。
Frame.decode(SDK_EVENT_FRAME[:-1])
def test_event_reply_keeps_frame_and_encodes_result():
"""
事件应答复用原帧字段并追加 biz_rt,处理结果以 base64 JSON 放在 data。
"""
received = []
conn = _connection(lambda payload: received.append(payload) or {"toast": {"content": "操作已提交"}})
reply = Frame.decode(conn._handle_frame(_event_frame(b'{"k": 1}').encode()))
assert received == [b'{"k": 1}']
assert (reply.seq_id, reply.log_id, reply.service, reply.method) == (3, 9, 7, 1)
assert reply.header("message_id") == "m1"
assert reply.header("biz_rt") is not None
body = json.loads(reply.payload)
assert body["code"] == 200
assert json.loads(base64.b64decode(body["data"])) == {"toast": {"content": "操作已提交"}}
def test_event_reply_without_result_and_on_failure():
"""
无返回值时只回 code 200;处理异常时回 500,连接不中断。
"""
assert json.loads(Frame.decode(_connection()._handle_frame(_event_frame(b"{}").encode())).payload) == {
"code": 200
}
def fail(payload):
raise RuntimeError("boom")
reply = Frame.decode(_connection(fail)._handle_frame(_event_frame(b"{}").encode()))
assert json.loads(reply.payload) == {"code": 500}
def test_card_type_frame_is_ignored_without_reply():
"""
card 类型帧与 SDK 一致:不分发也不应答。
"""
received = []
conn = _connection(received.append)
assert conn._handle_frame(_event_frame(b"{}", message_type="card").encode()) is None
assert received == []
def test_fragmented_event_is_combined_before_dispatch():
"""
分包事件在全部分片到达后按 seq 顺序拼接再分发,只对最后一片应答。
"""
received = []
conn = _connection(received.append)
first = conn._handle_frame(_event_frame(b"world", message_id="big", sum="2", seq="1").encode())
last = conn._handle_frame(_event_frame(b"hello ", message_id="big", sum="2", seq="0").encode())
assert first is None
assert last is not None
assert received == [b"hello world"]
assert conn._fragments == {}
def test_expired_fragments_are_dropped(monkeypatch):
"""
超过合包等待时间的分片被丢弃,迟到的分片不会拼出残缺事件。
"""
received = []
conn = _connection(received.append)
now = [100.0]
monkeypatch.setattr(longconn.time, "monotonic", lambda: now[0])
conn._handle_frame(_event_frame(b"a", message_id="late", sum="2", seq="0").encode())
now[0] += longconn._COMBINE_TTL_SECONDS + 1
assert conn._handle_frame(_event_frame(b"b", message_id="late", sum="2", seq="1").encode()) is None
assert received == []
def test_pong_payload_updates_client_config():
"""
pong 帧携带的 ClientConfig 覆盖心跳与重连参数。
"""
conn = _connection()
pong = Frame(method=0, headers=[("type", "pong")], payload=b'{"PingInterval": 15, "ReconnectCount": 3}')
assert conn._handle_frame(pong.encode()) is None
assert conn._config.ping_interval == 15
assert conn._config.reconnect_count == 3
assert conn._config.reconnect_interval == 120
def _endpoint_response(body, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: body)
@pytest.mark.parametrize(
("body", "error"),
[
({"code": 1, "msg": "busy"}, ConnectionError),
({"code": 1000040343, "msg": "internal"}, ConnectionError),
({"code": 10003, "msg": "invalid app secret"}, FatalConnectionError),
],
)
def test_endpoint_errors_are_classified(monkeypatch, body, error):
"""
繁忙与内部错误可重试,凭据等客户端错误视为致命。
"""
monkeypatch.setattr(longconn.RequestUtils, "post_res", lambda self, url, **kw: _endpoint_response(body))
with pytest.raises(error):
_connection()._fetch_endpoint()
def test_endpoint_success_applies_config(monkeypatch):
"""
地址接口成功时返回 URL 并应用服务端配置,请求体携带应用凭据。
"""
calls = []
def post_res(self, url, **kwargs):
calls.append((url, kwargs["json"]))
return _endpoint_response({
"code": 0,
"data": {"URL": "wss://example/ws?device_id=d&service_id=7", "ClientConfig": {"PingInterval": 30}},
})
monkeypatch.setattr(longconn.RequestUtils, "post_res", post_res)
conn = _connection()
assert conn._fetch_endpoint() == "wss://example/ws?device_id=d&service_id=7"
assert conn._config.ping_interval == 30
assert calls == [("https://open.feishu.cn/callback/ws/endpoint", {"AppID": "cli_app", "AppSecret": "secret"})]
@pytest.mark.parametrize(
("headers", "error"),
[
({"handshake-status": "403", "handshake-msg": "forbidden"}, FatalConnectionError),
(
{"Handshake-Status": "514", "handshake-msg": "limit", "handshake-autherrcode": "1000040350"},
FatalConnectionError,
),
({"handshake-status": "514", "handshake-msg": "auth", "handshake-autherrcode": "1"}, ConnectionError),
({}, ConnectionError),
],
)
def test_handshake_failures_are_classified(headers, error):
"""
无权限与连接数超限不再重连,其余握手失败按可重试处理。
"""
err = websocket.WebSocketBadStatusException("bad", 400, resp_headers=headers)
with pytest.raises(error):
FeishuLongConnection._raise_for_handshake(err)
def test_fatal_error_stops_reconnect(monkeypatch):
"""
致命错误让 run() 直接返回,不进入重连等待。
"""
attempts = []
def run_once(self):
attempts.append(1)
raise FatalConnectionError("invalid")
monkeypatch.setattr(FeishuLongConnection, "_run_once", run_once)
_connection().run()
assert attempts == [1]
def test_stop_requested_during_connection_error_stops_without_reconnect(monkeypatch):
"""
连接异常发生后若已收到停止请求,run() 应立即退出而不再重连。
"""
conn = _connection()
def fail_after_stop():
"""
模拟底层连接先收到停止请求、随后抛出断连异常。
"""
conn._stop_event.set()
raise ConnectionError("peer closed")
monkeypatch.setattr(conn, "_run_once", fail_after_stop)
conn.run()
def test_reconnect_count_limit(monkeypatch):
"""
服务端限定重连次数时,连续失败超过上限后停止。
"""
attempts = []
def run_once(self):
attempts.append(1)
raise ConnectionError("down")
monkeypatch.setattr(FeishuLongConnection, "_run_once", run_once)
conn = _connection()
conn._config.update({"ReconnectCount": 2, "ReconnectInterval": 0, "ReconnectNonce": 0})
monkeypatch.setattr(conn._stop_event, "wait", lambda timeout=None: False)
conn.run()
assert len(attempts) == 3
def test_reconnects_indefinitely_after_established_connections_drop(monkeypatch):
"""
连上后断开不计入连续失败:多次断线后仍按抖动重连,不会因重连次数上限永久退出。
"""
attempts = []
delays = []
def run_once(self):
attempts.append(1)
if len(attempts) == 6:
self._stop_event.set()
return
self._connected = True
raise ConnectionError("peer closed")
monkeypatch.setattr(FeishuLongConnection, "_run_once", run_once)
monkeypatch.setattr(longconn.random, "random", lambda: 0.5)
conn = _connection()
conn._config.update({"ReconnectCount": 2, "ReconnectInterval": 120, "ReconnectNonce": 10})
monkeypatch.setattr(conn._stop_event, "wait", lambda timeout=None: delays.append(timeout) or False)
conn.run()
assert len(attempts) == 6
assert delays == [5.0] * 5
class _SilentSocket:
"""
只接收 ping、从不回任何帧的连接,模拟链路静默断开。
"""
def __init__(self, clock):
self.clock = clock
self.sent = []
def send(self, data, opcode=None):
"""
解码发送帧并记录其类型,不发送真实网络数据。
"""
self.sent.append(Frame.decode(data).header("type"))
def settimeout(self, timeout):
"""
保存接收超时秒数,供模拟时钟推进使用。
"""
self.timeout = timeout
def recv_data(self):
"""
推进模拟时钟并抛出超时异常,模拟连接持续没有入站帧。
"""
self.clock[0] += self.timeout
raise websocket.WebSocketTimeoutException("timeout")
def test_silent_connection_is_detected(monkeypatch):
"""
超过心跳间隔加宽限仍无入站帧时抛出断线,交给 run() 重连。
"""
clock = [0.0]
monkeypatch.setattr(longconn.time, "monotonic", lambda: clock[0])
conn = _connection()
conn._config.update({"PingInterval": 10})
ws = _SilentSocket(clock)
with pytest.raises(ConnectionError):
conn._serve(ws, service_id=1)
assert clock[0] > 10 + longconn._LIVENESS_GRACE_SECONDS
assert set(ws.sent) == {"ping"}
def test_live_websocket_ping_event_ack_and_stop(monkeypatch):
"""
对本地 WebSocket 服务端完整走一遍:连接后先发 ping,收到事件后回写应答,
stop() 中断阻塞接收并让 run() 在预算内返回。
"""
server_frames = []
ack_received = threading.Event()
def handler(ws):
server_frames.append(Frame.decode(ws.recv()))
ws.send(_event_frame(b'{"header": {"event_type": "demo"}}').encode())
server_frames.append(Frame.decode(ws.recv()))
ack_received.set()
try:
ws.recv()
except Exception:
return
with serve(handler, "127.0.0.1", 0) as server:
port = server.socket.getsockname()[1]
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
monkeypatch.setattr(
FeishuLongConnection,
"_fetch_endpoint",
lambda self: f"ws://127.0.0.1:{port}/ws?device_id=dev1&service_id=42",
)
events = []
conn = _connection(lambda payload: events.append(payload) or {"ok": True})
client_thread = threading.Thread(target=conn.run, daemon=True)
client_thread.start()
assert ack_received.wait(5)
started = time.monotonic()
conn.stop()
client_thread.join(3)
server.shutdown()
assert not client_thread.is_alive()
assert time.monotonic() - started < 3
ping, ack = server_frames
assert ping.header("type") == "ping"
assert ping.service == 42
assert events == [b'{"header": {"event_type": "demo"}}']
assert ack.header("biz_rt") is not None
assert json.loads(ack.payload)["code"] == 200