375 lines
13 KiB
Python
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
|