from datetime import UTC, datetime, time import pytest from app.modules.subscriptions.constants import SubscriptionScheduleType from app.modules.subscriptions.services.schedule import ( ScheduleParseError, is_in_quiet_hours, next_occurrence, next_quiet_end, parse_schedule, ) NOW = datetime(2026, 7, 26, 0, 0, tzinfo=UTC) @pytest.mark.parametrize( ("expression", "schedule_type", "next_run_at"), [ ("今天 09:00", SubscriptionScheduleType.ONCE, datetime(2026, 7, 26, 1, 0)), ("明天 9点半", SubscriptionScheduleType.ONCE, datetime(2026, 7, 27, 1, 30)), ( "2026-08-01 10:15", SubscriptionScheduleType.ONCE, datetime(2026, 8, 1, 2, 15), ), ("每天 09:00", SubscriptionScheduleType.DAILY, datetime(2026, 7, 26, 1, 0)), ("工作日 09:00", SubscriptionScheduleType.WEEKDAY, datetime(2026, 7, 27, 1, 0)), ("每周一 09:00", SubscriptionScheduleType.WEEKLY, datetime(2026, 7, 27, 1, 0)), ("每月26号 09:00", SubscriptionScheduleType.MONTHLY, datetime(2026, 7, 26, 1, 0)), ( "每隔 2 小时", SubscriptionScheduleType.INTERVAL, datetime(2026, 7, 26, 2, 0), ), ], ) def test_parse_supported_chinese_schedules( expression: str, schedule_type: str, next_run_at: datetime, ) -> None: result = parse_schedule(expression, "Asia/Shanghai", now=NOW) assert result.schedule_type == schedule_type assert result.next_run_at == next_run_at assert result.timezone == "Asia/Shanghai" def test_monthly_schedule_skips_months_without_the_requested_day() -> None: result = parse_schedule( "每月31号 09:00", "Asia/Shanghai", now=datetime(2026, 4, 30, 2, 0, tzinfo=UTC), ) assert result.next_run_at == datetime(2026, 5, 31, 1, 0) assert ( next_occurrence( result.schedule_type, result.schedule_config, result.timezone, after=datetime(2026, 5, 31, 1, 0), ) == datetime(2026, 7, 31, 1, 0) ) @pytest.mark.parametrize( "expression", [ "每隔 14 分钟", "有空时提醒我", "今天 07:59", "2026-02-30 09:00", "每天 25:00", ], ) def test_invalid_or_ambiguous_schedule_is_rejected(expression: str) -> None: with pytest.raises(ScheduleParseError): parse_schedule(expression, "Asia/Shanghai", now=NOW) def test_invalid_iana_timezone_is_rejected() -> None: with pytest.raises(ScheduleParseError, match="Invalid IANA timezone"): parse_schedule("每天 09:00", "Mars/Olympus", now=NOW) def test_quiet_hours_support_cross_midnight_windows() -> None: current = datetime(2026, 7, 26, 15, 30, tzinfo=UTC) assert is_in_quiet_hours( current, "Asia/Shanghai", time(22, 0), time(7, 0), ) assert next_quiet_end( current, "Asia/Shanghai", time(22, 0), time(7, 0), ) == datetime(2026, 7, 26, 23, 0)