```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
375
tests/test_subscription_delivery.py
Normal file
375
tests/test_subscription_delivery.py
Normal file
@@ -0,0 +1,375 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.delivery.subscriptions import FeishuSubscriptionSender
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu.errors import FeishuAPIError
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.subscriptions.constants import (
|
||||
MAX_DAILY_DELIVERIES,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services import (
|
||||
DeliveryGenerationRequest,
|
||||
DeliverySendRequest,
|
||||
DeliveryService,
|
||||
)
|
||||
|
||||
|
||||
class StubGenerator:
|
||||
def __init__(self, content: str = "生成后的提醒") -> None:
|
||||
self.content = content
|
||||
self.requests: list[DeliveryGenerationRequest] = []
|
||||
|
||||
def generate(self, request: DeliveryGenerationRequest) -> str:
|
||||
self.requests.append(request)
|
||||
return self.content
|
||||
|
||||
|
||||
class StubSender:
|
||||
def __init__(self, outcomes: list[dict[str, Any] | Exception]) -> None:
|
||||
self.outcomes = outcomes
|
||||
self.requests: list[DeliverySendRequest] = []
|
||||
|
||||
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
|
||||
self.requests.append(request)
|
||||
outcome = self.outcomes.pop(0)
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class StubFeishuService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def send_text(self, text: str, **kwargs: Any) -> dict[str, Any]:
|
||||
self.calls.append({"text": text, **kwargs})
|
||||
return {"code": 0}
|
||||
|
||||
|
||||
def _delivery_fixture(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
current: datetime,
|
||||
target_type: str = SubscriptionTargetType.USER,
|
||||
) -> tuple[FeishuUser, PushSubscription, PushDelivery]:
|
||||
owner = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
role=(
|
||||
FeishuUserRole.ADMIN
|
||||
if target_type == SubscriptionTargetType.CHAT
|
||||
else FeishuUserRole.USER
|
||||
),
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
db.add(owner)
|
||||
db.flush()
|
||||
subscription = PushSubscription(
|
||||
code=f"SUB-{suffix}",
|
||||
owner_id=owner.id,
|
||||
target_type=target_type,
|
||||
target_id=(
|
||||
f"chat-{suffix}"
|
||||
if target_type == SubscriptionTargetType.CHAT
|
||||
else owner.open_id
|
||||
),
|
||||
prompt="按我的偏好生成提醒",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone=owner.timezone,
|
||||
next_run_at=current + timedelta(days=1),
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=current - timedelta(days=1),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.flush()
|
||||
delivery = PushDelivery(
|
||||
code=f"DEL-{suffix}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=current,
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.PENDING,
|
||||
next_attempt_at=current,
|
||||
created_at=current,
|
||||
updated_at=current,
|
||||
)
|
||||
db.add(delivery)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
db.refresh(subscription)
|
||||
db.refresh(delivery)
|
||||
return owner, subscription, delivery
|
||||
|
||||
|
||||
def test_private_delivery_uses_personal_context_open_id_and_is_idempotent() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
owner, _, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="private",
|
||||
current=current,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender(
|
||||
[{"code": 0, "data": {"message_id": "provider-message"}}]
|
||||
)
|
||||
service = DeliveryService(db, generator=generator, sender=sender)
|
||||
|
||||
sent = service.process(delivery.code, now=current)
|
||||
duplicate = service.process(delivery.code, now=current)
|
||||
|
||||
assert sent.status == PushDeliveryStatus.SENT
|
||||
assert sent.attempt_count == 1
|
||||
assert sent.provider_message_id == "provider-message"
|
||||
assert duplicate.status == PushDeliveryStatus.SENT
|
||||
assert len(generator.requests) == 1
|
||||
assert generator.requests[0] == DeliveryGenerationRequest(
|
||||
prompt="按我的偏好生成提醒",
|
||||
owner_id=owner.id,
|
||||
use_personal_context=True,
|
||||
use_company_rules=False,
|
||||
)
|
||||
assert len(sender.requests) == 1
|
||||
assert sender.requests[0].receive_id == owner.open_id
|
||||
assert sender.requests[0].receive_id_type == "open_id"
|
||||
assert sender.requests[0].tenant_key == owner.tenant_key
|
||||
assert sender.requests[0].uuid == delivery.message_uuid
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_feishu_sender_forwards_delivery_tenant_key() -> None:
|
||||
feishu = StubFeishuService()
|
||||
sender = FeishuSubscriptionSender(feishu) # type: ignore[arg-type]
|
||||
request = DeliverySendRequest(
|
||||
receive_id="open-user",
|
||||
receive_id_type="open_id",
|
||||
tenant_key="tenant-a",
|
||||
text="提醒内容",
|
||||
uuid="stable-delivery-uuid",
|
||||
)
|
||||
|
||||
result = sender.send(request)
|
||||
|
||||
assert result == {"code": 0}
|
||||
assert feishu.calls == [
|
||||
{
|
||||
"text": "提醒内容",
|
||||
"receive_id": "open-user",
|
||||
"receive_id_type": "open_id",
|
||||
"actor": "scheduler",
|
||||
"uuid": "stable-delivery-uuid",
|
||||
"tenant_key": "tenant-a",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_group_delivery_never_exposes_creator_personal_context() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
owner, subscription, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="group",
|
||||
current=current,
|
||||
target_type=SubscriptionTargetType.CHAT,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 0, "data": {"message_id": "group-message"}}])
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.SENT
|
||||
assert generator.requests[0].owner_id is None
|
||||
assert generator.requests[0].use_personal_context is False
|
||||
assert generator.requests[0].use_company_rules is True
|
||||
assert generator.requests[0].allow_tools is False
|
||||
assert generator.requests[0].record_history is False
|
||||
assert sender.requests[0].receive_id == subscription.target_id
|
||||
assert sender.requests[0].receive_id_type == "chat_id"
|
||||
assert sender.requests[0].tenant_key == owner.tenant_key
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_business_failure_retries_after_one_five_and_fifteen_minutes() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
first_at = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, _, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="retry",
|
||||
current=first_at,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 999}] * 4)
|
||||
service = DeliveryService(db, generator=generator, sender=sender)
|
||||
|
||||
first = service.process(delivery.code, now=first_at)
|
||||
assert first.status == PushDeliveryStatus.RETRY
|
||||
assert first.attempt_count == 1
|
||||
assert first.next_attempt_at == first_at + timedelta(minutes=1)
|
||||
|
||||
too_early = service.process(
|
||||
delivery.code,
|
||||
now=first_at + timedelta(seconds=30),
|
||||
)
|
||||
assert too_early.attempt_count == 1
|
||||
assert len(sender.requests) == 1
|
||||
|
||||
second_at = first_at + timedelta(minutes=1)
|
||||
second = service.process(delivery.code, now=second_at)
|
||||
assert second.status == PushDeliveryStatus.RETRY
|
||||
assert second.attempt_count == 2
|
||||
assert second.next_attempt_at == second_at + timedelta(minutes=5)
|
||||
|
||||
third_at = second_at + timedelta(minutes=5)
|
||||
third = service.process(delivery.code, now=third_at)
|
||||
assert third.status == PushDeliveryStatus.RETRY
|
||||
assert third.attempt_count == 3
|
||||
assert third.next_attempt_at == third_at + timedelta(minutes=15)
|
||||
|
||||
fourth_at = third_at + timedelta(minutes=15)
|
||||
fourth = service.process(delivery.code, now=fourth_at)
|
||||
assert fourth.status == PushDeliveryStatus.FAILED
|
||||
assert fourth.attempt_count == 4
|
||||
assert fourth.next_attempt_at is None
|
||||
assert len(generator.requests) == 1
|
||||
assert len({request.uuid for request in sender.requests}) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_paused_subscription_is_skipped_without_generation_or_send() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, subscription, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="paused",
|
||||
current=current,
|
||||
)
|
||||
subscription.status = PushSubscriptionStatus.PAUSED
|
||||
db.commit()
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 0}])
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.SKIPPED
|
||||
assert result.attempt_count == 0
|
||||
assert generator.requests == []
|
||||
assert sender.requests == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_non_retryable_feishu_client_error_fails_without_retry() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, _, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="permanent-feishu",
|
||||
current=current,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender(
|
||||
[
|
||||
FeishuAPIError(
|
||||
"invalid receive target",
|
||||
retryable=False,
|
||||
http_status=400,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.FAILED
|
||||
assert result.attempt_count == 1
|
||||
assert result.next_attempt_at is None
|
||||
assert len(sender.requests) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_send_time_daily_limit_counts_deferred_deliveries_by_sent_at() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, subscription, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="send-limit",
|
||||
current=current,
|
||||
)
|
||||
for index in range(MAX_DAILY_DELIVERIES):
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code=f"DEL-SENT-{index}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=current - timedelta(days=1, minutes=index + 1),
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.SENT,
|
||||
attempt_count=1,
|
||||
sent_at=current - timedelta(seconds=index + 1),
|
||||
created_at=current - timedelta(days=1),
|
||||
updated_at=current,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 0}])
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.SKIPPED
|
||||
assert result.attempt_count == 1
|
||||
assert "Daily delivery limit" in str(result.last_error)
|
||||
assert sender.requests == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
Reference in New Issue
Block a user