Files
company-ai-platform/tests/test_feishu_long_connection_health.py
JiuContinent eb8267ed18 ```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
2026-07-27 17:14:37 +08:00

227 lines
7.1 KiB
Python

from types import SimpleNamespace
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.core.database.migrations import expected_alembic_heads
from app.modules.feishu import long_connection
from app.modules.observability.constants import (
HeartbeatComponent,
HeartbeatStatus,
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.routes import router as observability_router
from app.modules.observability.service import ObservabilityService
def test_connection_heartbeat_tracks_disconnect_and_recovery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = SimpleNamespace(_conn=None)
statuses: list[str] = []
class FakeStopEvent:
def __init__(self) -> None:
self.wait_count = 0
def is_set(self) -> bool:
return self.wait_count >= 3
def wait(self, timeout: float) -> None:
assert timeout == 1
self.wait_count += 1
if self.wait_count == 1:
client._conn = SimpleNamespace(
state=SimpleNamespace(name="OPEN"),
)
elif self.wait_count == 2:
client._conn = SimpleNamespace(
state=SimpleNamespace(name="CLOSED"),
)
monkeypatch.setattr(
long_connection,
"_record_runtime_heartbeat",
lambda _instance_id, status_value: statuses.append(status_value),
)
long_connection._heartbeat_loop(
FakeStopEvent(), # type: ignore[arg-type]
"feishu-events-test",
30,
client,
)
assert statuses == [
HeartbeatStatus.DEGRADED,
HeartbeatStatus.OK,
HeartbeatStatus.DEGRADED,
]
def test_connection_adapter_supports_sdk_state_variants() -> None:
assert long_connection._sdk_connection_is_open(SimpleNamespace(_conn=None)) is False
unknown_client = SimpleNamespace(_conn=SimpleNamespace())
assert long_connection._sdk_connection_is_open(unknown_client) is False
assert (
long_connection._connection_heartbeat_status(unknown_client)
== HeartbeatStatus.DEGRADED
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(_conn=SimpleNamespace(closed=False))
)
is True
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(_conn=SimpleNamespace(open=False))
)
is False
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(
_conn=SimpleNamespace(state=SimpleNamespace(name="OPEN"))
)
)
is True
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(
_conn=SimpleNamespace(state=SimpleNamespace(name="CLOSING"))
)
)
is False
)
def test_readiness_returns_503_while_disconnected_and_recovers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("FEISHU_EVENT_TRANSPORT", "long_connection")
monkeypatch.setenv("FEISHU_APP_ID", "app-id")
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
get_settings.cache_clear()
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(
text("create table alembic_version (version_num varchar(32))")
)
connection.execute(
text("insert into alembic_version (version_num) values (:revision)"),
{"revision": expected_alembic_heads()[0]},
)
session_factory = sessionmaker(bind=engine, expire_on_commit=False)
app = FastAPI()
app.include_router(observability_router, prefix="/api/v1")
def override_get_db() -> Any:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
try:
with Session(engine) as db:
ObservabilityService(db).record_heartbeat(
HeartbeatComponent.FEISHU_EVENTS,
"feishu-events-test",
status_value=HeartbeatStatus.DEGRADED,
)
disconnected = client.get("/api/v1/health/ready")
assert disconnected.status_code == 503
disconnected_check = disconnected.json()[ObservabilityKey.CHECKS][
ObservabilityKey.FEISHU_EVENTS
]
assert (
disconnected_check[ObservabilityKey.STATUS]
== ObservabilityStatus.DEGRADED
)
assert disconnected_check["reason"] == "heartbeat_degraded"
with Session(engine) as db:
ObservabilityService(db).record_heartbeat(
HeartbeatComponent.FEISHU_EVENTS,
"feishu-events-test",
status_value=HeartbeatStatus.OK,
)
recovered = client.get("/api/v1/health/ready")
assert recovered.status_code == 200
recovered_check = recovered.json()[ObservabilityKey.CHECKS][
ObservabilityKey.FEISHU_EVENTS
]
assert recovered_check[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert recovered_check["reason"] is None
finally:
client.close()
engine.dispose()
get_settings.cache_clear()
@pytest.mark.parametrize(
("configured_identities", "expected_reason"),
[
([""], "admin_identities_missing"),
(["not-an-identity"], "admin_identities_invalid"),
],
)
def test_production_readiness_parses_admin_identities_without_exposing_them(
monkeypatch: pytest.MonkeyPatch,
configured_identities: list[str],
expected_reason: str,
) -> None:
sensitive_value = configured_identities[0]
settings = SimpleNamespace(
app_env="production",
feishu_user_features_enabled=True,
feishu_admin_identities=configured_identities,
feishu_event_transport="long_connection",
feishu_app_id="app-id",
feishu_app_secret="app-secret",
feishu_app_type="self",
feishu_default_tenant_key=None,
)
monkeypatch.setattr(
"app.modules.observability.service.get_settings",
lambda: settings,
)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert expected_reason in result["reasons"]
if sensitive_value:
assert sensitive_value not in str(result)
finally:
engine.dispose()