feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
328 lines
11 KiB
Python
328 lines
11 KiB
Python
import json
|
|
from datetime import datetime, timedelta
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.database import Base
|
|
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
|
from app.modules.feishu_users.models import FeishuUser
|
|
from app.modules.observability.constants import (
|
|
ObservabilityKey,
|
|
ObservabilityStatus,
|
|
)
|
|
from app.modules.observability.service import ObservabilityService
|
|
from app.modules.subscriptions.constants import (
|
|
PushDeliveryStatus,
|
|
PushSubscriptionStatus,
|
|
SubscriptionScheduleType,
|
|
SubscriptionTargetType,
|
|
)
|
|
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_settings() -> None:
|
|
get_settings.cache_clear()
|
|
yield
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def _add_active_subscription(
|
|
db: Session,
|
|
*,
|
|
tenant_key: str,
|
|
suffix: str,
|
|
) -> PushSubscription:
|
|
owner = FeishuUser(
|
|
code=f"FSU-{suffix}",
|
|
tenant_key=tenant_key,
|
|
open_id=f"open-{suffix}",
|
|
)
|
|
db.add(owner)
|
|
db.flush()
|
|
subscription = PushSubscription(
|
|
code=f"SUB-{suffix}",
|
|
owner_id=owner.id,
|
|
target_type=SubscriptionTargetType.USER,
|
|
target_id=owner.open_id,
|
|
prompt="发送个人提醒",
|
|
schedule_type=SubscriptionScheduleType.DAILY,
|
|
schedule_config={"hour": 9, "minute": 0},
|
|
timezone="Asia/Shanghai",
|
|
next_run_at=datetime(2026, 7, 27, 1, 0),
|
|
status=PushSubscriptionStatus.ACTIVE,
|
|
consented_at=datetime(2026, 7, 26, 1, 0),
|
|
)
|
|
db.add(subscription)
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
return subscription
|
|
|
|
|
|
def _add_delivery(
|
|
db: Session,
|
|
subscription: PushSubscription,
|
|
*,
|
|
suffix: str,
|
|
status_value: str,
|
|
scheduled_for: datetime,
|
|
) -> None:
|
|
future = datetime(2030, 1, 1, 0, 0)
|
|
db.add(
|
|
PushDelivery(
|
|
code=f"DEL-{suffix}",
|
|
subscription_id=subscription.id,
|
|
scheduled_for=scheduled_for,
|
|
idempotency_key=uuid4().hex + uuid4().hex,
|
|
message_uuid=str(uuid4()),
|
|
status=status_value,
|
|
next_attempt_at=(
|
|
future
|
|
if status_value
|
|
in {
|
|
PushDeliveryStatus.PENDING,
|
|
PushDeliveryStatus.RETRY,
|
|
}
|
|
else None
|
|
),
|
|
locked_by=(
|
|
"readiness-worker"
|
|
if status_value == PushDeliveryStatus.PROCESSING
|
|
else None
|
|
),
|
|
locked_until=(
|
|
future
|
|
if status_value == PushDeliveryStatus.PROCESSING
|
|
else None
|
|
),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def test_subscription_readiness_requires_basic_credentials(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
_add_active_subscription(db, tenant_key="tenant-a", suffix="missing")
|
|
|
|
result = ObservabilityService(db)._feishu_subscriptions_check()
|
|
|
|
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert result["credentials_configured"] is False
|
|
assert result["reasons"] == ["credentials_missing"]
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_self_app_readiness_rejects_multiple_active_tenants(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
_add_active_subscription(db, tenant_key="tenant-a", suffix="self-a")
|
|
single = ObservabilityService(db)._feishu_subscriptions_check()
|
|
assert single[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
assert single["active_tenant_count"] == 1
|
|
|
|
_add_active_subscription(db, tenant_key="tenant-b", suffix="self-b")
|
|
multiple = ObservabilityService(db)._feishu_subscriptions_check()
|
|
assert multiple[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert multiple["active_tenant_count"] == 2
|
|
assert multiple["reasons"] == ["self_app_multiple_tenants"]
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_store_app_readiness_accepts_persisted_ticket_without_exposing_it(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
ticket = "readiness-ticket-secret"
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
|
monkeypatch.setenv("FEISHU_APP_TICKET", "")
|
|
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "tenant-a")
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
_add_active_subscription(db, tenant_key="tenant-a", suffix="store")
|
|
missing = ObservabilityService(db)._feishu_subscriptions_check()
|
|
assert missing[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert missing["reasons"] == ["app_ticket_missing"]
|
|
|
|
FeishuAppTicketService(db).store_verified("cli-store", ticket)
|
|
configured = ObservabilityService(db)._feishu_subscriptions_check()
|
|
|
|
assert configured[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
assert configured["ticket_configured"] is True
|
|
assert configured["default_tenant_configured"] is True
|
|
assert configured["reasons"] == []
|
|
assert ticket not in json.dumps(configured)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_store_app_readiness_requires_default_tenant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
|
monkeypatch.setenv("FEISHU_APP_TICKET", "environment-ticket")
|
|
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "")
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
_add_active_subscription(db, tenant_key="tenant-a", suffix="default")
|
|
|
|
result = ObservabilityService(db)._feishu_subscriptions_check()
|
|
|
|
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert result["ticket_configured"] is True
|
|
assert result["default_tenant_configured"] is False
|
|
assert result["reasons"] == ["default_tenant_missing"]
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_readiness_checks_credentials_for_processable_deliveries_without_active_plan(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
subscription = _add_active_subscription(
|
|
db,
|
|
tenant_key="tenant-a",
|
|
suffix="durable",
|
|
)
|
|
subscription.status = PushSubscriptionStatus.COMPLETED
|
|
subscription.next_run_at = None
|
|
db.commit()
|
|
scheduled_for = datetime(2026, 7, 27, 1, 0)
|
|
for index, status_value in enumerate(
|
|
[
|
|
PushDeliveryStatus.PENDING,
|
|
PushDeliveryStatus.RETRY,
|
|
PushDeliveryStatus.PROCESSING,
|
|
]
|
|
):
|
|
_add_delivery(
|
|
db,
|
|
subscription,
|
|
suffix=f"durable-{index}",
|
|
status_value=status_value,
|
|
scheduled_for=scheduled_for + timedelta(minutes=index),
|
|
)
|
|
|
|
result = ObservabilityService(db)._feishu_subscriptions_check()
|
|
|
|
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert result["active"] == 0
|
|
assert result["processable_deliveries"] == 3
|
|
assert result["active_tenant_count"] == 1
|
|
assert result["reasons"] == ["credentials_missing"]
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_readiness_ignores_terminal_deliveries_without_active_plan() -> None:
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
subscription = _add_active_subscription(
|
|
db,
|
|
tenant_key="tenant-a",
|
|
suffix="terminal",
|
|
)
|
|
subscription.status = PushSubscriptionStatus.COMPLETED
|
|
subscription.next_run_at = None
|
|
db.commit()
|
|
scheduled_for = datetime(2026, 7, 27, 1, 0)
|
|
for index, status_value in enumerate(
|
|
[
|
|
PushDeliveryStatus.SENT,
|
|
PushDeliveryStatus.FAILED,
|
|
PushDeliveryStatus.SKIPPED,
|
|
]
|
|
):
|
|
_add_delivery(
|
|
db,
|
|
subscription,
|
|
suffix=f"terminal-{index}",
|
|
status_value=status_value,
|
|
scheduled_for=scheduled_for + timedelta(minutes=index),
|
|
)
|
|
|
|
result = ObservabilityService(db)._feishu_subscriptions_check()
|
|
|
|
assert result == {
|
|
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
|
|
"active": 0,
|
|
"processable_deliveries": 0,
|
|
}
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_self_app_counts_tenants_from_processable_deliveries(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
scheduled_for = datetime(2026, 7, 27, 1, 0)
|
|
for index, tenant_key in enumerate(["tenant-a", "tenant-b"]):
|
|
subscription = _add_active_subscription(
|
|
db,
|
|
tenant_key=tenant_key,
|
|
suffix=f"delivery-tenant-{index}",
|
|
)
|
|
subscription.status = PushSubscriptionStatus.COMPLETED
|
|
subscription.next_run_at = None
|
|
db.commit()
|
|
_add_delivery(
|
|
db,
|
|
subscription,
|
|
suffix=f"delivery-tenant-{index}",
|
|
status_value=PushDeliveryStatus.PENDING,
|
|
scheduled_for=scheduled_for + timedelta(minutes=index),
|
|
)
|
|
|
|
result = ObservabilityService(db)._feishu_subscriptions_check()
|
|
|
|
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert result["active"] == 0
|
|
assert result["processable_deliveries"] == 2
|
|
assert result["active_tenant_count"] == 2
|
|
assert result["reasons"] == ["self_app_multiple_tenants"]
|
|
finally:
|
|
engine.dispose()
|