PMFlow/backend/tests/test_feishu_long_connection.py

132 lines
5.0 KiB
Python

import unittest
from unittest.mock import patch
from app.services.feishu_long_connection import FeishuLongConnectionManager
class _AliveThread:
def __init__(self) -> None:
self._alive = True
def is_alive(self) -> bool:
return self._alive
def join(self, timeout: float | None = None) -> None:
_ = timeout
self._alive = False
class _StubFeishuLongConnectionManager(FeishuLongConnectionManager):
def __init__(self, config: dict | None, preflight_exc: Exception | None = None) -> None:
super().__init__(session_factory=None) # type: ignore[arg-type]
self._sdk_available = True
self._stub_config = config
self._preflight_exc = preflight_exc
self.start_calls: list[tuple[str, str]] = []
async def _load_active_config(self) -> dict | None:
if self._stub_config is None:
return None
return dict(self._stub_config)
async def _preflight_auth(self, *, tenant_id: int) -> None:
_ = tenant_id
if self._preflight_exc is not None:
raise self._preflight_exc
def _start_ws_thread(self, app_id: str, app_secret: str) -> None:
self.start_calls.append((app_id, app_secret))
self._thread = _AliveThread()
class FeishuLongConnectionManagerTest(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self) -> None:
manager = getattr(self, "manager", None)
if manager is not None and manager.status().get("running"):
await manager.stop()
async def test_auth_failure_is_classified_without_starting_thread(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 9,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
},
preflight_exc=ValueError("获取 tenant_access_token 失败(http=401): {'code': 99991663}"),
)
await self.manager.start()
status = self.manager.status()
self.assertEqual(status["tenant_id"], 9)
self.assertEqual(status["last_error_code"], "auth_failed")
self.assertFalse(status["last_error_retryable"])
self.assertFalse(status["thread_alive"])
self.assertEqual(self.manager.start_calls, [])
async def test_network_failure_is_marked_retryable(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 3,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
},
preflight_exc=ValueError("访问飞书 auth 接口失败: timed out"),
)
await self.manager.start()
status = self.manager.status()
self.assertEqual(status["last_error_code"], "network_error")
self.assertTrue(status["last_error_retryable"])
self.assertEqual(status["restart_attempts"], 1)
self.assertIsNotNone(status["next_retry_at"])
self.assertEqual(self.manager.start_calls, [])
async def test_successful_start_records_tenant_and_auth_timestamp(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 12,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
}
)
await self.manager.start()
status = self.manager.status()
self.assertEqual(status["tenant_id"], 12)
self.assertTrue(status["thread_alive"])
self.assertIsNotNone(status["last_auth_at"])
self.assertEqual(self.manager.start_calls, [("cli_xxx", "secret_xxx")])
async def test_retry_strategy_is_1_2_4_and_stops_after_three_attempts(self) -> None:
self.manager = _StubFeishuLongConnectionManager(
config={
"tenant_id": 7,
"event_mode": "long_connection",
"app_id": "cli_xxx",
"app_secret": "secret_xxx",
}
)
self.manager._set_error("network", code="network_error", retryable=True)
with patch("app.services.feishu_long_connection.time.time", return_value=100.0):
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 1)
self.assertEqual(self.manager._next_retry_at, 101.0)
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 2)
self.assertEqual(self.manager._next_retry_at, 102.0)
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 3)
self.assertEqual(self.manager._next_retry_at, 104.0)
# 第 4 次触发应熔断,不再继续重试。
self.manager._schedule_retry()
self.assertEqual(self.manager._restart_attempts, 3)
self.assertEqual(self.manager._next_retry_at, 0.0)
self.assertFalse(self.manager._last_error_retryable)