```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
327
tests/test_feishu_subscription_readiness.py
Normal file
327
tests/test_feishu_subscription_readiness.py
Normal file
@@ -0,0 +1,327 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user