# -*- coding: utf-8 -*- """Scheduler 单元测试 覆盖: - calculate_next_run:各种调度类型的下次执行时间计算 - _parse_simple_cron:简单 cron 表达式解析 - check_and_enqueue:到期检查与入队逻辑 - start / stop:后台循环生命周期 """ import asyncio import json from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch import pytest from app.schemas.schedules import ScheduleConfigSchema from app.schemas.tasks import TaskConfigSchema from app.services.scheduler import ( Scheduler, calculate_next_run, _parse_simple_cron, _parse_time, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def sched() -> Scheduler: return Scheduler() @pytest.fixture def now() -> datetime: """固定时间点:2025-06-10 10:00:00 UTC(周二)""" return datetime(2025, 6, 10, 10, 0, 0, tzinfo=timezone.utc) def _mock_cursor(fetchone_val=None, fetchall_val=None, rowcount=1): cur = MagicMock() cur.fetchone.return_value = fetchone_val cur.fetchall.return_value = fetchall_val or [] cur.rowcount = rowcount cur.__enter__ = MagicMock(return_value=cur) cur.__exit__ = MagicMock(return_value=False) return cur def _mock_conn(cursor): conn = MagicMock() conn.cursor.return_value = cursor return conn # --------------------------------------------------------------------------- # _parse_time # --------------------------------------------------------------------------- class TestParseTime: def test_standard_format(self): assert _parse_time("04:00") == (4, 0) def test_with_minutes(self): assert _parse_time("23:45") == (23, 45) def test_midnight(self): assert _parse_time("00:00") == (0, 0) # --------------------------------------------------------------------------- # calculate_next_run — once # --------------------------------------------------------------------------- class TestNextRunOnce: def test_once_returns_none(self, now): cfg = ScheduleConfigSchema(schedule_type="once") assert calculate_next_run(cfg, now) is None # --------------------------------------------------------------------------- # calculate_next_run — interval # --------------------------------------------------------------------------- class TestNextRunInterval: def test_interval_minutes(self, now): cfg = ScheduleConfigSchema( schedule_type="interval", interval_value=15, interval_unit="minutes", ) result = calculate_next_run(cfg, now) assert result == now + timedelta(minutes=15) def test_interval_hours(self, now): cfg = ScheduleConfigSchema( schedule_type="interval", interval_value=2, interval_unit="hours", ) result = calculate_next_run(cfg, now) assert result == now + timedelta(hours=2) def test_interval_days(self, now): cfg = ScheduleConfigSchema( schedule_type="interval", interval_value=3, interval_unit="days", ) result = calculate_next_run(cfg, now) assert result == now + timedelta(days=3) # --------------------------------------------------------------------------- # calculate_next_run — daily # --------------------------------------------------------------------------- class TestNextRunDaily: def test_daily_next_day(self, now): cfg = ScheduleConfigSchema(schedule_type="daily", daily_time="04:00") result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 11, 4, 0, 0, tzinfo=timezone.utc) assert result == expected def test_daily_custom_time(self, now): cfg = ScheduleConfigSchema(schedule_type="daily", daily_time="18:30") result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 11, 18, 30, 0, tzinfo=timezone.utc) assert result == expected # --------------------------------------------------------------------------- # calculate_next_run — weekly # --------------------------------------------------------------------------- class TestNextRunWeekly: def test_weekly_later_this_week(self, now): # now 是周二(2),weekly_days=[5] 周五 → 3 天后 cfg = ScheduleConfigSchema( schedule_type="weekly", weekly_days=[5], weekly_time="08:00", ) result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 13, 8, 0, 0, tzinfo=timezone.utc) assert result == expected def test_weekly_next_week(self, now): # now 是周二(2),weekly_days=[1] 周一 → 下周一(6天后) cfg = ScheduleConfigSchema( schedule_type="weekly", weekly_days=[1], weekly_time="04:00", ) result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 16, 4, 0, 0, tzinfo=timezone.utc) assert result == expected def test_weekly_multiple_days_picks_next(self, now): # now 是周二(2),weekly_days=[1, 4, 6] → 周四(4),2 天后 cfg = ScheduleConfigSchema( schedule_type="weekly", weekly_days=[1, 4, 6], weekly_time="09:00", ) result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 12, 9, 0, 0, tzinfo=timezone.utc) assert result == expected # --------------------------------------------------------------------------- # calculate_next_run — cron # --------------------------------------------------------------------------- class TestNextRunCron: def test_cron_daily(self, now): cfg = ScheduleConfigSchema(schedule_type="cron", cron_expression="30 4 * * *") result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 11, 4, 30, 0, tzinfo=timezone.utc) assert result == expected def test_cron_with_dow(self, now): # "0 8 * * 5" → 每周五 08:00,now 是周二 → 周五(3天后) cfg = ScheduleConfigSchema(schedule_type="cron", cron_expression="0 8 * * 5") result = calculate_next_run(cfg, now) expected = datetime(2025, 6, 13, 8, 0, 0, tzinfo=timezone.utc) assert result == expected # --------------------------------------------------------------------------- # _parse_simple_cron # --------------------------------------------------------------------------- class TestParseSimpleCron: def test_daily_cron(self, now): result = _parse_simple_cron("0 4 * * *", now) expected = datetime(2025, 6, 11, 4, 0, 0, tzinfo=timezone.utc) assert result == expected def test_invalid_field_count_fallback(self, now): # 字段数不对,回退到明天 04:00 result = _parse_simple_cron("0 4 *", now) expected = datetime(2025, 6, 11, 4, 0, 0, tzinfo=timezone.utc) assert result == expected def test_wildcard_hour_minute(self, now): # "* * * * *" → hour=0, minute=0,明天 00:00 result = _parse_simple_cron("* * * * *", now) expected = datetime(2025, 6, 11, 0, 0, 0, tzinfo=timezone.utc) assert result == expected def test_dow_sunday(self, now): # "0 6 * * 0" → 每周日 06:00,now 是周二 → 周日(5天后) result = _parse_simple_cron("0 6 * * 0", now) expected = datetime(2025, 6, 15, 6, 0, 0, tzinfo=timezone.utc) assert result == expected def test_dow_same_day_future_time(self): # 周二 08:00,cron 指定周二 12:00 → 当天 now = datetime(2025, 6, 10, 8, 0, 0, tzinfo=timezone.utc) result = _parse_simple_cron("0 12 * * 2", now) expected = datetime(2025, 6, 10, 12, 0, 0, tzinfo=timezone.utc) assert result == expected def test_dow_same_day_past_time(self): # 周二 14:00,cron 指定周二 12:00 → 下周二 now = datetime(2025, 6, 10, 14, 0, 0, tzinfo=timezone.utc) result = _parse_simple_cron("0 12 * * 2", now) expected = datetime(2025, 6, 17, 12, 0, 0, tzinfo=timezone.utc) assert result == expected # --------------------------------------------------------------------------- # check_and_enqueue # --------------------------------------------------------------------------- class TestCheckAndEnqueue: @patch("app.services.scheduler.get_connection") @patch("app.services.scheduler.task_queue") def test_enqueues_due_tasks(self, mock_tq, mock_get_conn, sched): """到期任务应被入队,且更新 last_run_at / run_count / next_run_at""" task_config = {"tasks": ["ODS_MEMBER"], "pipeline": "api_ods_dwd"} schedule_config = { "schedule_type": "interval", "interval_value": 1, "interval_unit": "hours", } # 第一次 cursor:SELECT 到期任务 select_cur = _mock_cursor( fetchall_val=[ ("task-uuid-1", 42, json.dumps(task_config), json.dumps(schedule_config)), ] ) # 第二次 cursor:UPDATE update_cur = _mock_cursor() conn = MagicMock() # cursor() 依次返回 select_cur 和 update_cur conn.cursor.side_effect = [select_cur, update_cur] mock_get_conn.return_value = conn mock_tq.enqueue.return_value = "queue-id-1" count = sched.check_and_enqueue() assert count == 1 mock_tq.enqueue.assert_called_once() # 验证 enqueue 的参数 call_args = mock_tq.enqueue.call_args assert call_args[0][1] == 42 # site_id assert isinstance(call_args[0][0], TaskConfigSchema) @patch("app.services.scheduler.get_connection") @patch("app.services.scheduler.task_queue") def test_no_due_tasks(self, mock_tq, mock_get_conn, sched): """没有到期任务时,不入队""" cur = _mock_cursor(fetchall_val=[]) conn = _mock_conn(cur) mock_get_conn.return_value = conn count = sched.check_and_enqueue() assert count == 0 mock_tq.enqueue.assert_not_called() @patch("app.services.scheduler.get_connection") @patch("app.services.scheduler.task_queue") def test_skips_invalid_config(self, mock_tq, mock_get_conn, sched): """配置反序列化失败的任务应被跳过""" # task_config 缺少必填字段 tasks bad_config = {"pipeline": "api_ods_dwd"} schedule_config = {"schedule_type": "once"} cur = _mock_cursor( fetchall_val=[ ("task-uuid-bad", 42, json.dumps(bad_config), json.dumps(schedule_config)), ] ) conn = _mock_conn(cur) mock_get_conn.return_value = conn count = sched.check_and_enqueue() assert count == 0 mock_tq.enqueue.assert_not_called() @patch("app.services.scheduler.get_connection") @patch("app.services.scheduler.task_queue") def test_enqueue_failure_continues(self, mock_tq, mock_get_conn, sched): """入队失败时应跳过该任务,继续处理后续任务""" task_config = {"tasks": ["ODS_MEMBER"], "pipeline": "api_ods_dwd"} schedule_config = {"schedule_type": "once"} cur = _mock_cursor( fetchall_val=[ ("task-1", 42, json.dumps(task_config), json.dumps(schedule_config)), ("task-2", 42, json.dumps(task_config), json.dumps(schedule_config)), ] ) # 需要额外的 cursor 给 UPDATE 用 update_cur = _mock_cursor() conn = MagicMock() conn.cursor.side_effect = [cur, update_cur] mock_get_conn.return_value = conn # 第一次入队失败,第二次成功 mock_tq.enqueue.side_effect = [Exception("DB error"), "queue-id-2"] count = sched.check_and_enqueue() assert count == 1 assert mock_tq.enqueue.call_count == 2 @patch("app.services.scheduler.get_connection") @patch("app.services.scheduler.task_queue") def test_once_type_sets_next_run_none(self, mock_tq, mock_get_conn, sched): """once 类型任务入队后,next_run_at 应被设为 NULL""" task_config = {"tasks": ["ODS_MEMBER"], "pipeline": "api_ods_dwd"} schedule_config = {"schedule_type": "once"} select_cur = _mock_cursor( fetchall_val=[ ("task-uuid-1", 42, json.dumps(task_config), json.dumps(schedule_config)), ] ) update_cur = _mock_cursor() conn = MagicMock() conn.cursor.side_effect = [select_cur, update_cur] mock_get_conn.return_value = conn mock_tq.enqueue.return_value = "queue-id-1" sched.check_and_enqueue() # 验证 UPDATE 语句中 next_run_at 参数为 None update_call = update_cur.__enter__().execute.call_args # 参数元组的第一个元素是 next_run_at assert update_call[0][1][0] is None # --------------------------------------------------------------------------- # start / stop 生命周期 # --------------------------------------------------------------------------- class TestLifecycle: @pytest.mark.asyncio async def test_stop_sets_running_false(self, sched): sched._running = True await sched.stop() assert sched._running is False assert sched._loop_task is None def test_start_creates_task(self, sched): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # 在事件循环中启动 async def _run(): sched.start() assert sched._loop_task is not None assert not sched._loop_task.done() await sched.stop() loop.run_until_complete(_run()) finally: loop.close() @pytest.mark.asyncio async def test_start_stop_idempotent(self, sched): """多次 stop 不应报错""" await sched.stop() await sched.stop() assert sched._loop_task is None