import asyncio import os import certifi import pytest import lark_oapi as lark import lark_oapi.ws.client as lark_ws_client from app.services.integrations.feishu_integration_service.mixin_config import FeishuConfigMixin from app.services.integrations.feishu_long_connection_core.mixin_lifecycle_connection import ( LongConnectionLifecycleConnectionMixin, ) from app.services.integrations.feishu_long_connection_core.mixin_lifecycle_control import ( LongConnectionLifecycleControlMixin, ) from app.services.integrations.feishu_long_connection_core.mixin_runtime import ( LongConnectionRuntimeMixin, ) from app.services.integrations.feishu_long_connection_core.shared import ( ensure_feishu_tls_trust_bundle, ) @pytest.fixture(autouse=True) def _clear_tls_env(monkeypatch: pytest.MonkeyPatch) -> None: for key in ( "FEISHU_LONG_CONNECTION_SSL_CERT_FILE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", ): monkeypatch.delenv(key, raising=False) def test_ensure_feishu_tls_trust_bundle_defaults_to_certifi(monkeypatch: pytest.MonkeyPatch) -> None: bundle = ensure_feishu_tls_trust_bundle() assert bundle["bundle_path"] == certifi.where() assert bundle["source"] == "certifi" assert os.environ["SSL_CERT_FILE"] == certifi.where() assert os.environ["REQUESTS_CA_BUNDLE"] == certifi.where() assert os.environ["CURL_CA_BUNDLE"] == certifi.where() def test_ensure_feishu_tls_trust_bundle_honors_explicit_override(monkeypatch: pytest.MonkeyPatch) -> None: override_path = "/tmp/custom-feishu-ca.pem" monkeypatch.setenv("FEISHU_LONG_CONNECTION_SSL_CERT_FILE", override_path) bundle = ensure_feishu_tls_trust_bundle() assert bundle["bundle_path"] == override_path assert bundle["source"] == "FEISHU_LONG_CONNECTION_SSL_CERT_FILE" assert os.environ["SSL_CERT_FILE"] == override_path assert os.environ["REQUESTS_CA_BUNDLE"] == override_path assert os.environ["CURL_CA_BUNDLE"] == override_path class _DummyLongConnectionManager(LongConnectionLifecycleConnectionMixin): def __init__(self) -> None: self._boot_lock = asyncio.Lock() self._queue = asyncio.Queue() self._running = True self._thread = None self._client = None self._sdk_available = True self._configured = False self._event_mode = "long_connection" self._tenant_id = 1 self._last_error = None self._last_error_code = None self._last_error_retryable = False self._last_received_at = None self._last_received_message_id = None self._last_event_at = None self._last_auth_at = None self._last_connect_at = None self._last_disconnect_at = None self._restart_attempts = 0 self._next_retry_at = 0.0 self._stopping = False self._max_retry_attempts = 3 self._retry_base_seconds = 1.0 async def _load_active_config(self) -> dict[str, object] | None: return { "tenant_id": 1, "app_id": "app-id", "app_secret": "app-secret", "encrypt_key": "encrypt-key", "verification_token": "verification-token", } async def _preflight_auth(self, *, tenant_id: int) -> None: # noqa: ARG002 return None def _start_ws_thread( self, app_id: str, app_secret: str, *, encrypt_key: str = "", verification_token: str = "", ) -> None: self.started_with = (app_id, app_secret, encrypt_key, verification_token) class _FakeResult: def __init__(self, value) -> None: self._value = value def scalar_one_or_none(self): return self._value class _FakeDB: def __init__(self, integration) -> None: self._integration = integration self.committed = False async def execute(self, query): # noqa: ARG002 return _FakeResult(self._integration) async def commit(self) -> None: self.committed = True @pytest.mark.asyncio async def test_feishu_config_load_preserves_event_credentials() -> None: integration = type( "IntegrationRow", (), { "config": { "app_id": "app-id", "app_secret": "app-secret", "event_mode": "long_connection", "encrypt_key": "encrypt-key", "verification_token": "verification-token", }, "tenant_id": 1, }, )() service = FeishuConfigMixin(_FakeDB(integration)) await service.load_config(tenant_id=1) assert service.app_id == "app-id" assert service.app_secret == "app-secret" assert service.encrypt_key == "encrypt-key" assert service.verification_token == "verification-token" assert service.event_mode == "long_connection" @pytest.mark.asyncio async def test_feishu_config_save_preserves_existing_event_credentials() -> None: integration = type( "IntegrationRow", (), { "config": { "app_id": "old-app", "app_secret": "old-secret", "event_mode": "long_connection", "encrypt_key": "existing-encrypt-key", "verification_token": "existing-verification-token", }, "tenant_id": 1, "is_active": True, }, )() service = FeishuConfigMixin(_FakeDB(integration)) await service.save_config( app_id="new-app", app_secret="new-secret", encrypt_key="", verification_token="", event_mode="long_connection", tenant_id=1, ) assert integration.config["app_id"] == "new-app" assert integration.config["app_secret"] == "new-secret" assert integration.config["encrypt_key"] == "existing-encrypt-key" assert integration.config["verification_token"] == "existing-verification-token" assert service.encrypt_key == "existing-encrypt-key" assert service.verification_token == "existing-verification-token" assert service.app_id == "new-app" assert service.app_secret == "new-secret" assert service.event_mode == "long_connection" assert service.db.committed is True @pytest.mark.asyncio async def test_ensure_connection_bootstraps_tls_bundle_before_ws_start() -> None: manager = _DummyLongConnectionManager() started = await manager._ensure_connection(reason="startup") assert started is True assert manager.started_with == ( "app-id", "app-secret", "encrypt-key", "verification-token", ) assert os.environ["SSL_CERT_FILE"] == certifi.where() assert os.environ["REQUESTS_CA_BUNDLE"] == certifi.where() assert os.environ["CURL_CA_BUNDLE"] == certifi.where() @pytest.mark.asyncio async def test_long_connection_status_tracks_received_and_processed_times() -> None: class _RuntimeDummy(LongConnectionRuntimeMixin): def __init__(self) -> None: self._running = True self._loop = asyncio.get_running_loop() self._queue = asyncio.Queue() self._tenant_id = 1 self._last_received_at = None self._last_received_message_id = None self._last_event_at = None manager = _RuntimeDummy() manager._loop = asyncio.get_running_loop() manager._on_message_event( { "header": {"event_type": "im.message.receive_v1"}, "event": {"message": {"message_id": "msg-123"}}, } ) await asyncio.sleep(0) assert manager._last_received_message_id == "msg-123" assert manager._last_received_at is not None assert manager._queue.qsize() == 1 lifecycle_manager = type("StatusOnly", (), {})() lifecycle_manager._running = True lifecycle_manager._tenant_id = 1 lifecycle_manager._event_mode = "long_connection" lifecycle_manager._sdk_available = True lifecycle_manager._configured = True lifecycle_manager._worker_task = None lifecycle_manager._supervisor_task = None lifecycle_manager._thread = None lifecycle_manager._last_error = None lifecycle_manager._last_error_code = None lifecycle_manager._last_error_retryable = False lifecycle_manager._last_received_at = manager._last_received_at lifecycle_manager._last_received_message_id = manager._last_received_message_id lifecycle_manager._last_event_at = None lifecycle_manager._last_auth_at = None lifecycle_manager._last_connect_at = None lifecycle_manager._last_disconnect_at = None lifecycle_manager._restart_attempts = 0 lifecycle_manager._next_retry_at = 0.0 lifecycle_manager._max_retry_attempts = 3 lifecycle_manager._retry_base_seconds = 1.0 status = LongConnectionLifecycleControlMixin.status(lifecycle_manager) assert status["last_received_message_id"] == "msg-123" assert status["last_received_at"] == manager._last_received_at assert status["last_event_at"] is None @pytest.mark.asyncio async def test_long_connection_card_action_returns_ack_and_enqueues_payload() -> None: class _RuntimeDummy(LongConnectionRuntimeMixin): def __init__(self) -> None: self._running = True self._loop = asyncio.get_running_loop() self._queue = asyncio.Queue() self._tenant_id = 1 self._last_error = None self._last_error_code = None self._last_error_retryable = False manager = _RuntimeDummy() result = manager._on_card_action_event( { "schema": "p2", "header": {"event_type": "card.action.trigger", "token": "token"}, "event": { "operator": {"open_id": "ou_abc", "name": "测试用户"}, "context": {"open_message_id": "om_123", "open_chat_id": "oc_456"}, "action": {"value": {"action": "confirm", "work_item_id": 99}}, }, } ) await asyncio.sleep(0) assert result.toast is not None assert result.toast.type == "info" assert result.toast.content == "已收到,正在处理" assert manager._queue.qsize() == 1 queued = await manager._queue.get() assert queued["header"]["event_type"] == "card.action.trigger" assert queued["event"]["context"]["open_message_id"] == "om_123" @pytest.mark.asyncio async def test_run_ws_client_uses_thread_local_event_loop(monkeypatch: pytest.MonkeyPatch) -> None: original_ws_loop = lark_ws_client.loop captured: dict[str, object] = {} class _FakeBuilder: def register_p2_im_message_receive_v1(self, callback): # noqa: ARG002 captured["register_receive_called"] = True return self def register_p2_card_action_trigger(self, callback): # noqa: ARG002 captured["register_card_action_called"] = True return self def register_p2_im_message_message_read_v1(self, callback): # noqa: ARG002 return self def register_p2_im_message_reaction_created_v1(self, callback): # noqa: ARG002 return self def register_p2_im_message_reaction_deleted_v1(self, callback): # noqa: ARG002 return self def register_p2_im_message_recalled_v1(self, callback): # noqa: ARG002 return self def build(self): captured["builder_built"] = True return object() class _FakeEventDispatcherHandler: @staticmethod def builder(encrypt_key: str, verification_token: str): # noqa: ARG002 captured["builder_loop"] = lark_ws_client.loop return _FakeBuilder() class _FakeClient: def __init__(self, app_id, app_secret, event_handler=None, log_level=None): # noqa: ARG002 captured["client_init_loop"] = lark_ws_client.loop def start(self): captured["client_start_loop"] = lark_ws_client.loop def stop(self): captured["client_stop_called"] = True monkeypatch.setattr(lark, "EventDispatcherHandler", _FakeEventDispatcherHandler) monkeypatch.setattr(lark.ws, "Client", _FakeClient) class _RuntimeDummy(LongConnectionRuntimeMixin): def __init__(self) -> None: self._running = False self._loop = None self._queue = asyncio.Queue() self._tenant_id = 1 self._last_error = None self._last_error_code = None self._last_error_retryable = False self._client = None manager = _RuntimeDummy() manager._run_ws_client("app-id", "app-secret") assert captured["register_receive_called"] is True assert captured["register_card_action_called"] is True assert captured["builder_built"] is True assert captured["client_init_loop"] is not original_ws_loop assert captured["client_start_loop"] is captured["client_init_loop"] assert lark_ws_client.loop is original_ws_loop