PMFlow/backend/tests/test_feishu_long_connection...

265 lines
8.8 KiB
Python

import asyncio
import os
import certifi
import pytest
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