Files
Neo-ZQYY/apps/backend/tests/test_scheduler.py

385 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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:00now 是周二 → 周五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:00now 是周二 → 周日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:00cron 指定周二 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:00cron 指定周二 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",
}
# 第一次 cursorSELECT 到期任务
select_cur = _mock_cursor(
fetchall_val=[
("task-uuid-1", 42, json.dumps(task_config), json.dumps(schedule_config)),
]
)
# 第二次 cursorUPDATE
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