```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
360
tests/test_subscription_runtime_wiring.py
Normal file
360
tests/test_subscription_runtime_wiring.py
Normal file
@@ -0,0 +1,360 @@
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import app.core.background.task_queue.subscriptions as subscription_queue
|
||||
from app.application.scheduling import create_scheduler
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.observability.constants import (
|
||||
ObservabilityKey,
|
||||
ObservabilityStatus,
|
||||
)
|
||||
from app.modules.observability.routes import router as observability_router
|
||||
from app.modules.subscriptions.constants import (
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.routes import router as subscriptions_router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_settings() -> Iterator[None]:
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[sessionmaker[Session]]:
|
||||
monkeypatch.setenv("API_KEY", "subscription-service-key")
|
||||
monkeypatch.setenv("API_KEYS", "[]")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _seed_subscription(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
status_value: str = PushSubscriptionStatus.ACTIVE,
|
||||
) -> PushSubscription:
|
||||
owner = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
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(2030, 1, 1, 1, 0)
|
||||
if status_value == PushSubscriptionStatus.ACTIVE
|
||||
else None
|
||||
),
|
||||
status=status_value,
|
||||
consented_at=datetime(2026, 7, 26, 1, 0),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
return subscription
|
||||
|
||||
|
||||
def _seed_delivery(
|
||||
db: Session,
|
||||
subscription: PushSubscription,
|
||||
*,
|
||||
suffix: str,
|
||||
status_value: str,
|
||||
) -> PushDelivery:
|
||||
delivery = PushDelivery(
|
||||
code=f"DEL-{suffix}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=datetime(2026, 7, 27, 1, 0),
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=status_value,
|
||||
attempt_count=2,
|
||||
next_attempt_at=datetime(2030, 1, 1, 1, 0),
|
||||
last_error="temporary provider failure",
|
||||
)
|
||||
db.add(delivery)
|
||||
db.commit()
|
||||
db.refresh(delivery)
|
||||
return delivery
|
||||
|
||||
|
||||
def _test_app(
|
||||
session_factory: sessionmaker[Session],
|
||||
*,
|
||||
include_subscriptions: bool = False,
|
||||
include_observability: bool = False,
|
||||
) -> FastAPI:
|
||||
app = FastAPI()
|
||||
if include_subscriptions:
|
||||
app.include_router(
|
||||
subscriptions_router,
|
||||
prefix="/api/v1/subscriptions",
|
||||
)
|
||||
if include_observability:
|
||||
app.include_router(observability_router, prefix="/api/v1")
|
||||
|
||||
def override_db() -> Iterator[Session]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return app
|
||||
|
||||
|
||||
def test_subscription_internal_apis_require_key_and_serialize_records(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
subscription = _seed_subscription(db, suffix="api")
|
||||
delivery = _seed_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix="api",
|
||||
status_value=PushDeliveryStatus.RETRY,
|
||||
)
|
||||
|
||||
client = TestClient(
|
||||
_test_app(session_factory, include_subscriptions=True)
|
||||
)
|
||||
assert client.get("/api/v1/subscriptions").status_code == 401
|
||||
assert client.get("/api/v1/subscriptions/deliveries").status_code == 401
|
||||
assert (
|
||||
client.get(
|
||||
"/api/v1/subscriptions",
|
||||
headers={"X-API-Key": "invalid-key"},
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
headers = {"X-API-Key": "subscription-service-key"}
|
||||
subscriptions_response = client.get(
|
||||
"/api/v1/subscriptions",
|
||||
headers=headers,
|
||||
)
|
||||
deliveries_response = client.get(
|
||||
"/api/v1/subscriptions/deliveries",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert subscriptions_response.status_code == 200
|
||||
subscriptions_body = subscriptions_response.json()
|
||||
assert subscriptions_body["total"] == 1
|
||||
subscription_item = subscriptions_body["items"][0]
|
||||
assert set(subscription_item) == {
|
||||
"code",
|
||||
"owner_id",
|
||||
"target_type",
|
||||
"target_id",
|
||||
"prompt",
|
||||
"schedule_type",
|
||||
"schedule_config",
|
||||
"timezone",
|
||||
"next_run_at",
|
||||
"status",
|
||||
"consented_at",
|
||||
"last_run_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert subscription_item["code"] == subscription.code
|
||||
assert subscription_item["schedule_config"] == {"hour": 9, "minute": 0}
|
||||
assert subscription_item["next_run_at"] is not None
|
||||
|
||||
assert deliveries_response.status_code == 200
|
||||
deliveries_body = deliveries_response.json()
|
||||
assert deliveries_body["total"] == 1
|
||||
delivery_item = deliveries_body["items"][0]
|
||||
assert set(delivery_item) == {
|
||||
"code",
|
||||
"subscription_id",
|
||||
"scheduled_for",
|
||||
"idempotency_key",
|
||||
"message_uuid",
|
||||
"status",
|
||||
"attempt_count",
|
||||
"next_attempt_at",
|
||||
"provider_message_id",
|
||||
"last_error",
|
||||
"sent_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert delivery_item["code"] == delivery.code
|
||||
assert delivery_item["message_uuid"] == delivery.message_uuid
|
||||
assert delivery_item["attempt_count"] == 2
|
||||
assert "rendered_content" not in delivery_item
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("features_enabled", "job_expected"),
|
||||
[(False, False), (True, True)],
|
||||
)
|
||||
def test_subscription_scheduler_job_follows_feature_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
features_enabled: bool,
|
||||
job_expected: bool,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FEISHU_USER_FEATURES_ENABLED",
|
||||
str(features_enabled).lower(),
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
|
||||
scheduler = create_scheduler()
|
||||
job = scheduler.get_job("subscription_delivery_cycle")
|
||||
|
||||
assert scheduler.running is False
|
||||
assert (job is not None) is job_expected
|
||||
if job is not None:
|
||||
assert job.trigger.interval == timedelta(minutes=1)
|
||||
assert scheduler._job_defaults["coalesce"] is True
|
||||
assert scheduler._job_defaults["max_instances"] == 1
|
||||
|
||||
|
||||
def test_subscription_queue_runs_inline_without_celery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_cycle(*, actor: str) -> dict[str, list[str]]:
|
||||
calls.append(actor)
|
||||
return {"created": [], "processed": []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_queue,
|
||||
"run_subscription_cycle",
|
||||
fake_cycle,
|
||||
)
|
||||
|
||||
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
|
||||
|
||||
assert result == {
|
||||
"queued": False,
|
||||
"mode": "inline",
|
||||
"task_name": "subscriptions.run_cycle",
|
||||
"result": {"created": [], "processed": []},
|
||||
}
|
||||
assert calls == ["runtime-test"]
|
||||
|
||||
|
||||
def test_subscription_queue_uses_celery_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.tasks import celery_app
|
||||
|
||||
monkeypatch.setenv("TASK_QUEUE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeSignature:
|
||||
def apply_async(self) -> SimpleNamespace:
|
||||
calls["applied"] = True
|
||||
return SimpleNamespace(id="queued-task-id")
|
||||
|
||||
def fake_signature(
|
||||
task_name: str,
|
||||
*,
|
||||
kwargs: dict[str, str],
|
||||
) -> FakeSignature:
|
||||
calls["task_name"] = task_name
|
||||
calls["kwargs"] = kwargs
|
||||
return FakeSignature()
|
||||
|
||||
monkeypatch.setattr(celery_app, "signature", fake_signature)
|
||||
monkeypatch.setattr(
|
||||
subscription_queue,
|
||||
"run_subscription_cycle",
|
||||
lambda **_: pytest.fail("Celery dispatch must not run inline"),
|
||||
)
|
||||
|
||||
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
|
||||
|
||||
assert result == {
|
||||
"queued": True,
|
||||
"mode": "celery",
|
||||
"task_name": "subscriptions.run_cycle",
|
||||
"task_id": "queued-task-id",
|
||||
}
|
||||
assert calls == {
|
||||
"task_name": "subscriptions.run_cycle",
|
||||
"kwargs": {"actor": "runtime-test"},
|
||||
"applied": True,
|
||||
}
|
||||
|
||||
|
||||
def test_ready_route_returns_503_for_processable_delivery_without_credentials(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
get_settings.cache_clear()
|
||||
with session_factory() as db:
|
||||
subscription = _seed_subscription(
|
||||
db,
|
||||
suffix="ready",
|
||||
status_value=PushSubscriptionStatus.COMPLETED,
|
||||
)
|
||||
_seed_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix="ready",
|
||||
status_value=PushDeliveryStatus.PENDING,
|
||||
)
|
||||
|
||||
client = TestClient(
|
||||
_test_app(session_factory, include_observability=True)
|
||||
)
|
||||
response = client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 503
|
||||
body = response.json()
|
||||
assert body[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
check = body[ObservabilityKey.CHECKS]["feishu_subscriptions"]
|
||||
assert check[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert check["active"] == 0
|
||||
assert check["processable_deliveries"] == 1
|
||||
assert check["reasons"] == ["credentials_missing"]
|
||||
assert "生成一条个人提醒" not in response.text
|
||||
Reference in New Issue
Block a user