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)