PMFlow/backend/tests/test_feishu_long_connection...

375 lines
13 KiB
Python

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