```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
300
tests/test_subscription_dispatch.py
Normal file
300
tests/test_subscription_dispatch.py
Normal file
@@ -0,0 +1,300 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.subscriptions.constants import (
|
||||
DAILY_DELIVERY_LIMIT_REACHED,
|
||||
MAX_ACTIVE_SUBSCRIPTIONS,
|
||||
MAX_DAILY_DELIVERIES,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services import (
|
||||
SubscriptionManagementService,
|
||||
SubscriptionScanner,
|
||||
)
|
||||
|
||||
|
||||
def _user(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
role: str = FeishuUserRole.USER,
|
||||
quiet_start: time | None = None,
|
||||
quiet_end: time | None = None,
|
||||
) -> FeishuUser:
|
||||
record = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
role=role,
|
||||
timezone="Asia/Shanghai",
|
||||
quiet_hours_start=quiet_start,
|
||||
quiet_hours_end=quiet_end,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def _subscription(
|
||||
db: Session,
|
||||
owner: FeishuUser,
|
||||
*,
|
||||
code: str,
|
||||
next_run_at: datetime,
|
||||
target_type: str = SubscriptionTargetType.USER,
|
||||
target_id: str | None = None,
|
||||
) -> PushSubscription:
|
||||
record = PushSubscription(
|
||||
code=code,
|
||||
owner_id=owner.id,
|
||||
target_type=target_type,
|
||||
target_id=target_id or owner.open_id,
|
||||
prompt="给我一条简短提醒",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone=owner.timezone,
|
||||
next_run_at=next_run_at,
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=next_run_at - timedelta(days=1),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def test_management_binds_private_and_group_targets() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="user")
|
||||
private_principal = FeishuPrincipal.from_user(
|
||||
user,
|
||||
chat_id="private-chat",
|
||||
chat_type="p2p",
|
||||
)
|
||||
private, _ = SubscriptionManagementService(db).create_private(
|
||||
private_principal,
|
||||
"每天 09:00",
|
||||
"给我一条提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
|
||||
assert private.target_type == SubscriptionTargetType.USER
|
||||
assert private.target_id == user.open_id
|
||||
|
||||
with pytest.raises(HTTPException) as ordinary_group:
|
||||
SubscriptionManagementService(db).create_group(
|
||||
FeishuPrincipal.from_user(
|
||||
user,
|
||||
chat_id="group-chat",
|
||||
chat_type="group",
|
||||
),
|
||||
"每天 10:00",
|
||||
"群提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
assert ordinary_group.value.status_code == 403
|
||||
|
||||
admin = _user(db, suffix="admin", role=FeishuUserRole.ADMIN)
|
||||
with pytest.raises(HTTPException, match="current group"):
|
||||
SubscriptionManagementService(db).create_group(
|
||||
FeishuPrincipal.from_user(
|
||||
admin,
|
||||
chat_id="manually-supplied-chat",
|
||||
chat_type="p2p",
|
||||
),
|
||||
"每天 10:00",
|
||||
"群提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
|
||||
group, _ = SubscriptionManagementService(db).create_group(
|
||||
FeishuPrincipal.from_user(
|
||||
admin,
|
||||
chat_id="verified-current-group",
|
||||
chat_type="group",
|
||||
),
|
||||
"每天 10:00",
|
||||
"群提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
assert group.target_type == SubscriptionTargetType.CHAT
|
||||
assert group.target_id == "verified-current-group"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_management_rejects_more_than_fifty_active_subscriptions() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="limit")
|
||||
for index in range(MAX_ACTIVE_SUBSCRIPTIONS):
|
||||
_subscription(
|
||||
db,
|
||||
user,
|
||||
code=f"SUB-LIMIT-{index}",
|
||||
next_run_at=datetime(2026, 7, 27, 1, 0),
|
||||
)
|
||||
with pytest.raises(HTTPException, match="at most 50"):
|
||||
SubscriptionManagementService(db).create_private(
|
||||
FeishuPrincipal.from_user(user),
|
||||
"每天 09:00",
|
||||
"第 51 条",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_scanner_materializes_one_window_and_advances_the_plan() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="scan")
|
||||
subscription = _subscription(
|
||||
db,
|
||||
user,
|
||||
code="SUB-SCAN",
|
||||
next_run_at=current,
|
||||
)
|
||||
|
||||
first = SubscriptionScanner(db).scan_due(now=current)
|
||||
second = SubscriptionScanner(db).scan_due(now=current)
|
||||
|
||||
assert len(first) == 1
|
||||
assert second == []
|
||||
assert first[0].scheduled_for == current
|
||||
assert first[0].status == PushDeliveryStatus.PENDING
|
||||
assert first[0].next_attempt_at == current
|
||||
db.refresh(subscription)
|
||||
assert subscription.next_run_at == datetime(2026, 7, 27, 1, 0)
|
||||
assert db.scalar(select(func.count()).select_from(PushDelivery)) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_scanner_defers_quiet_hours_and_skips_daily_limit() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 15, 30)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
quiet_user = _user(
|
||||
db,
|
||||
suffix="quiet",
|
||||
quiet_start=time(22, 0),
|
||||
quiet_end=time(7, 0),
|
||||
)
|
||||
_subscription(
|
||||
db,
|
||||
quiet_user,
|
||||
code="SUB-QUIET",
|
||||
next_run_at=current,
|
||||
)
|
||||
quiet_delivery = SubscriptionScanner(db).scan_due(now=current)[0]
|
||||
assert quiet_delivery.status == PushDeliveryStatus.PENDING
|
||||
assert quiet_delivery.next_attempt_at == datetime(2026, 7, 26, 23, 0)
|
||||
|
||||
limited_user = _user(db, suffix="daily-limit")
|
||||
history = _subscription(
|
||||
db,
|
||||
limited_user,
|
||||
code="SUB-HISTORY",
|
||||
next_run_at=current + timedelta(days=1),
|
||||
)
|
||||
for index in range(MAX_DAILY_DELIVERIES):
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code=f"DEL-HISTORY-{index}",
|
||||
subscription_id=history.id,
|
||||
scheduled_for=current - timedelta(minutes=index),
|
||||
idempotency_key=f"{index:064x}",
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.SENT,
|
||||
attempt_count=1,
|
||||
sent_at=current,
|
||||
created_at=current,
|
||||
updated_at=current,
|
||||
)
|
||||
)
|
||||
_subscription(
|
||||
db,
|
||||
limited_user,
|
||||
code="SUB-OVER-LIMIT",
|
||||
next_run_at=current,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
limited_delivery = SubscriptionScanner(db).scan_due(now=current)[0]
|
||||
assert limited_delivery.status == PushDeliveryStatus.SKIPPED
|
||||
assert limited_delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_two_sqlite_scanners_claim_only_one_delivery(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "subscription-scanner.db"
|
||||
engine = create_engine(
|
||||
f"sqlite:///{database_path}",
|
||||
connect_args={"check_same_thread": False, "timeout": 10},
|
||||
)
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with factory() as db:
|
||||
user = _user(db, suffix="concurrent")
|
||||
_subscription(
|
||||
db,
|
||||
user,
|
||||
code="SUB-CONCURRENT",
|
||||
next_run_at=current,
|
||||
)
|
||||
|
||||
barrier = Barrier(2)
|
||||
|
||||
def scan(worker_id: str) -> list[str]:
|
||||
with factory() as db:
|
||||
barrier.wait()
|
||||
return [
|
||||
delivery.code
|
||||
for delivery in SubscriptionScanner(db).scan_due(
|
||||
now=current,
|
||||
worker_id=worker_id,
|
||||
)
|
||||
]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(scan, ("scanner-one", "scanner-two")))
|
||||
|
||||
assert sum(len(items) for items in results) == 1
|
||||
with factory() as db:
|
||||
assert db.scalar(select(func.count()).select_from(PushDelivery)) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user