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()