import json from datetime import datetime from uuid import uuid4 import pytest from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session from app.application.feishu.commands import FeishuCommandService from app.application.feishu.handlers.subscriptions import handle_subscription_command from app.core.config import get_settings from app.core.database import Base from app.modules.ai_agent.service import AIService from app.modules.feishu.constants import FeishuCommandKey, FeishuCommandName from app.modules.feishu.service import FeishuService 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, PushDeliveryStatus, PushSubscriptionStatus, SubscriptionScheduleType, SubscriptionTargetType, ) from app.modules.subscriptions.models import PushDelivery, PushSubscription def _user( db: Session, *, suffix: str, role: str = FeishuUserRole.USER, ) -> FeishuUser: record = FeishuUser( code=f"FSU-{suffix}", tenant_key=f"tenant-{suffix}", open_id=f"open-{suffix}", role=role, timezone="Asia/Shanghai", ) db.add(record) db.commit() db.refresh(record) return record def _handle( db: Session, text: str, principal: FeishuPrincipal, ) -> dict: result = handle_subscription_command( db, FeishuService(db), text, principal, False, ) assert result is not None return result def test_create_private_subscription_replies_with_normalized_plan() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="private-command") principal = FeishuPrincipal.from_user( user, chat_id="private-chat", chat_type="p2p", ) result = _handle( db, "订阅 每天 09:00:请提醒我:喝水", principal, ) subscription = db.scalar(select(PushSubscription)) assert subscription is not None assert result["command"] == FeishuCommandName.SUBSCRIPTION_CREATE assert "订阅已启用" in result["content"] assert "计划:每天 09:00" in result["content"] assert "下次执行:" in result["content"] assert f"暂停订阅 {subscription.code}" in result["content"] assert subscription.owner_id == user.id assert subscription.target_type == SubscriptionTargetType.USER assert subscription.target_id == user.open_id assert subscription.prompt == "请提醒我:喝水" finally: engine.dispose() def test_rich_text_event_routes_subscription_instead_of_fallback_ai( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true") get_settings.cache_clear() engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="rich-text-command") principal = FeishuPrincipal.from_user( user, chat_id="private-chat", chat_type="p2p", ) text = "订阅 每隔 15 分钟:给我一句简短的工作提醒" payload = { "header": {"tenant_key": user.tenant_key}, "event": { "message": { "chat_id": "private-chat", "chat_type": "p2p", "content": json.dumps( { "content": [ [ { "tag": "text", "text": text, "style": [], } ] ] }, ensure_ascii=False, ), }, "sender": {"sender_id": {"open_id": user.open_id}}, }, } commands = FeishuCommandService(db) extracted = commands.extract_event_command(payload) assert extracted is not None assert extracted[FeishuCommandKey.TEXT] == text result = commands.handle_text( extracted[FeishuCommandKey.TEXT], chat_id="private-chat", principal=principal, auto_reply=False, ) subscription = db.scalar(select(PushSubscription)) assert result["command"] == FeishuCommandName.SUBSCRIPTION_CREATE assert "计划:每隔 15 分钟" in result["content"] assert subscription is not None assert subscription.prompt == "给我一句简短的工作提醒" finally: get_settings.cache_clear() engine.dispose() def test_rich_text_event_routes_help_instead_of_fallback_ai( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true") monkeypatch.setattr( AIService, "ask_personalized", lambda *args, **kwargs: pytest.fail("Rich-text help command reached AI fallback"), ) get_settings.cache_clear() engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="rich-text-help") principal = FeishuPrincipal.from_user( user, chat_id="private-chat", chat_type="p2p", ) payload = { "header": {"tenant_key": user.tenant_key}, "event": { "message": { "chat_id": "private-chat", "chat_type": "p2p", "content": json.dumps( { "content": [ [ { "tag": "text", "text": "帮助", "style": [], } ] ] }, ensure_ascii=False, ), }, "sender": {"sender_id": {"open_id": user.open_id}}, }, } commands = FeishuCommandService(db) extracted = commands.extract_event_command(payload) assert extracted is not None assert extracted[FeishuCommandKey.TEXT] == "帮助" result = commands.handle_text( extracted[FeishuCommandKey.TEXT], chat_id="private-chat", principal=principal, auto_reply=False, ) assert result["command"] == FeishuCommandName.HELP assert "你可以使用:" in result["content"] finally: get_settings.cache_clear() engine.dispose() def test_group_subscription_uses_current_verified_chat_and_requires_admin() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: ordinary = _user(db, suffix="ordinary-group") denied = _handle( db, "订阅 每周一 09:00:群提醒", FeishuPrincipal.from_user( ordinary, chat_id="verified-group", chat_type="group", ), ) assert "无权" in denied["content"] assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0 admin = _user(db, suffix="admin-group", role=FeishuUserRole.ADMIN) accepted = _handle( db, "订阅 每周一 09:00:群提醒", FeishuPrincipal.from_user( admin, chat_id="verified-group", chat_type="group", ), ) subscription = db.scalar(select(PushSubscription)) assert accepted["command"] == FeishuCommandName.SUBSCRIPTION_CREATE assert subscription is not None assert subscription.target_type == SubscriptionTargetType.CHAT assert subscription.target_id == "verified-group" finally: engine.dispose() def test_list_pause_resume_and_cancel_subscription_commands() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="lifecycle-command") principal = FeishuPrincipal.from_user(user, chat_type="p2p") _handle(db, "订阅 每天 10:00:生命周期测试", principal) subscription = db.scalar(select(PushSubscription)) assert subscription is not None listed = _handle(db, "我的订阅", principal) assert listed["command"] == FeishuCommandName.SUBSCRIPTION_LIST assert subscription.code in listed["content"] assert "每天 10:00" in listed["content"] paused = _handle(db, f"暂停订阅 {subscription.code}", principal) db.refresh(subscription) assert paused["command"] == FeishuCommandName.SUBSCRIPTION_PAUSE assert subscription.status == PushSubscriptionStatus.PAUSED resumed = _handle(db, f"恢复订阅 {subscription.code}", principal) db.refresh(subscription) assert resumed["command"] == FeishuCommandName.SUBSCRIPTION_RESUME assert subscription.status == PushSubscriptionStatus.ACTIVE assert "下次执行:" in resumed["content"] cancelled = _handle(db, f"退订 {subscription.code}", principal) db.refresh(subscription) assert cancelled["command"] == FeishuCommandName.SUBSCRIPTION_CANCEL assert subscription.status == PushSubscriptionStatus.CANCELLED assert subscription.next_run_at is None finally: engine.dispose() def test_timezone_and_quiet_hour_commands_update_current_user() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="settings-command") principal = FeishuPrincipal.from_user(user, chat_type="p2p") timezone_result = _handle(db, "设置时区 Asia/Tokyo", principal) db.refresh(user) assert timezone_result["command"] == FeishuCommandName.SUBSCRIPTION_TIMEZONE assert user.timezone == "Asia/Tokyo" quiet_result = _handle(db, "设置安静时段 22:00-07:00", principal) db.refresh(user) assert quiet_result["command"] == FeishuCommandName.SUBSCRIPTION_QUIET_HOURS assert user.quiet_hours_start.strftime("%H:%M") == "22:00" assert user.quiet_hours_end.strftime("%H:%M") == "07:00" closed = _handle(db, "关闭安静时段", principal) db.refresh(user) assert closed["command"] == FeishuCommandName.SUBSCRIPTION_QUIET_HOURS assert user.quiet_hours_start is None assert user.quiet_hours_end is None finally: engine.dispose() def test_invalid_schedule_and_capacity_error_create_no_partial_record() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="invalid-command") principal = FeishuPrincipal.from_user(user, chat_type="p2p") invalid = _handle( db, "订阅 每隔10分钟:过于频繁", principal, ) assert "示例" not in invalid["content"] assert "订阅 每天 09:00" in invalid["content"] assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0 for index in range(MAX_ACTIVE_SUBSCRIPTIONS): db.add( PushSubscription( code=f"SUB-CAPACITY-{index}", owner_id=user.id, target_type=SubscriptionTargetType.USER, target_id=user.open_id, prompt=f"已有订阅 {index}", schedule_type=SubscriptionScheduleType.DAILY, schedule_config={"hour": 9, "minute": 0}, timezone=user.timezone, next_run_at=datetime(2026, 7, 27, 1, 0), status=PushSubscriptionStatus.ACTIVE, consented_at=datetime(2026, 7, 26, 0, 0), ) ) db.commit() limited = _handle( db, "订阅 每天 11:00:第 51 条", principal, ) assert "最多 50 个" in limited["content"] assert ( db.scalar(select(func.count()).select_from(PushSubscription)) == MAX_ACTIVE_SUBSCRIPTIONS ) finally: engine.dispose() def test_unrelated_text_is_not_claimed_by_subscription_handler() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="unrelated-command") assert ( handle_subscription_command( db, FeishuService(db), "今天怎么样", FeishuPrincipal.from_user(user), False, ) is None ) finally: engine.dispose() def test_list_explains_daily_delivery_limit_skip() -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix="delivery-note") principal = FeishuPrincipal.from_user(user, chat_type="p2p") _handle(db, "订阅 每天 10:00:投递说明", principal) subscription = db.scalar(select(PushSubscription)) assert subscription is not None now = datetime(2026, 7, 26, 1, 0) db.add( PushDelivery( code="DEL-DAILY-LIMIT", subscription_id=subscription.id, scheduled_for=now, idempotency_key=uuid4().hex + uuid4().hex, message_uuid=str(uuid4()), status=PushDeliveryStatus.SKIPPED, next_attempt_at=None, last_error=DAILY_DELIVERY_LIMIT_REACHED, created_at=now, updated_at=now, ) ) db.commit() listed = _handle(db, "我的订阅", principal) assert "每日最多 96 条限制" in listed["content"] finally: engine.dispose() @pytest.mark.parametrize( "command", [ "设置时区 Invalid/Timezone", "设置安静时段 25:00-07:00", "订阅 每隔999999999999999999小时:不会创建", ], ) def test_invalid_settings_and_oversized_interval_return_command_error( command: str, ) -> None: engine = create_engine("sqlite://") Base.metadata.create_all(engine) try: with Session(engine) as db: user = _user(db, suffix=f"invalid-{abs(hash(command))}") principal = FeishuPrincipal.from_user(user, chat_type="p2p") result = _handle(db, command, principal) assert "未执行" in result["content"] or "无法识别" in result["content"] assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0 finally: engine.dispose()