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

440 lines
16 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 -*-
"""调度属性测试Property-Based Testing
使用 hypothesis 验证调度管理的通用正确性属性:
- Property 12: 调度任务 CRUD 往返
- Property 13: 到期调度任务自动入队
- Property 14: 调度任务启用/禁用状态
测试策略:
- Property 12: 通过 mock 数据库,验证 POST 创建后 GET 返回的 schedule_config 与提交的一致
- Property 13: 通过 mock 数据库返回到期任务,验证 check_and_enqueue 调用了 task_queue.enqueue
- Property 14: 通过 mock 数据库,验证 toggle 端点的 next_run_at 行为
"""
import os
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-schedule-properties")
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from app.auth.dependencies import CurrentUser, get_current_user
from app.main import app
from app.schemas.schedules import ScheduleConfigSchema
from app.schemas.tasks import TaskConfigSchema
from app.services.scheduler import Scheduler, calculate_next_run
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# 通用策略Strategies
# ---------------------------------------------------------------------------
_site_id_st = st.integers(min_value=1, max_value=2**31 - 1)
_task_codes = ["ODS_MEMBER", "ODS_PAYMENT", "ODS_ORDER", "DWD_LOAD_FROM_ODS", "DWS_SUMMARY"]
_simple_task_config_st = st.fixed_dictionaries({
"tasks": st.lists(st.sampled_from(_task_codes), min_size=1, max_size=3, unique=True),
"flow": st.sampled_from(["api_ods", "api_ods_dwd", "ods_dwd", "api_full"]),
})
# 调度配置策略:覆盖 5 种调度类型
_schedule_type_st = st.sampled_from(["once", "interval", "daily", "weekly", "cron"])
_interval_unit_st = st.sampled_from(["minutes", "hours", "days"])
# HH:MM 格式的时间字符串
_time_str_st = st.builds(
lambda h, m: f"{h:02d}:{m:02d}",
h=st.integers(min_value=0, max_value=23),
m=st.integers(min_value=0, max_value=59),
)
# ISO weekday 列表1=Monday ... 7=Sunday
_weekly_days_st = st.lists(
st.integers(min_value=1, max_value=7),
min_size=1, max_size=7, unique=True,
)
# 简单 cron 表达式minute hour * * *
_cron_st = st.builds(
lambda m, h: f"{m} {h} * * *",
m=st.integers(min_value=0, max_value=59),
h=st.integers(min_value=0, max_value=23),
)
def _build_schedule_config(schedule_type, interval_value, interval_unit,
daily_time, weekly_days, weekly_time, cron_expression):
"""根据 schedule_type 构建 ScheduleConfigSchema。"""
return ScheduleConfigSchema(
schedule_type=schedule_type,
interval_value=interval_value,
interval_unit=interval_unit,
daily_time=daily_time,
weekly_days=weekly_days,
weekly_time=weekly_time,
cron_expression=cron_expression,
enabled=True,
)
_schedule_config_st = st.builds(
_build_schedule_config,
schedule_type=_schedule_type_st,
interval_value=st.integers(min_value=1, max_value=168),
interval_unit=_interval_unit_st,
daily_time=_time_str_st,
weekly_days=_weekly_days_st,
weekly_time=_time_str_st,
cron_expression=_cron_st,
)
# 用于 Property 14 的非 once 调度配置(启用后 next_run_at 应非 NULL
_non_once_schedule_type_st = st.sampled_from(["interval", "daily", "weekly", "cron"])
_non_once_schedule_config_st = st.builds(
_build_schedule_config,
schedule_type=_non_once_schedule_type_st,
interval_value=st.integers(min_value=1, max_value=168),
interval_unit=_interval_unit_st,
daily_time=_time_str_st,
weekly_days=_weekly_days_st,
weekly_time=_time_str_st,
cron_expression=_cron_st,
)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
_NOW = datetime(2025, 6, 10, 10, 0, 0, tzinfo=timezone.utc)
# 模拟数据库行的列顺序(与 _SELECT_COLS 对应,共 13 列)
# id, site_id, name, task_codes, task_config, schedule_config,
# enabled, last_run_at, next_run_at, run_count, last_status,
# created_at, updated_at
def _make_db_row(
schedule_id: str,
site_id: int,
name: str,
task_codes: list[str],
task_config: dict,
schedule_config: dict,
enabled: bool = True,
next_run_at: datetime | None = None,
) -> tuple:
"""构造模拟数据库行。"""
return (
schedule_id, site_id, name, task_codes,
json.dumps(task_config) if isinstance(task_config, dict) else task_config,
json.dumps(schedule_config) if isinstance(schedule_config, dict) else schedule_config,
enabled, None, next_run_at, 0, None, _NOW, _NOW,
)
# ---------------------------------------------------------------------------
# Feature: admin-web-console, Property 12: 调度任务 CRUD 往返
# **Validates: Requirements 5.1, 5.4**
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
site_id=_site_id_st,
schedule_config=_schedule_config_st,
task_config=_simple_task_config_st,
name=st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=("L", "N"), whitelist_characters="_- "
)),
task_codes=st.lists(st.sampled_from(_task_codes), min_size=1, max_size=3, unique=True),
)
@patch("app.routers.schedules.get_connection")
def test_schedule_crud_round_trip(
mock_get_conn, site_id, schedule_config, task_config, name, task_codes,
):
"""Property 12: 调度任务 CRUD 往返。
有效的 ScheduleConfigSchema创建调度任务后再查询该任务
返回的调度配置应与创建时提交的配置等价。
"""
schedule_config_dict = schedule_config.model_dump()
next_run = calculate_next_run(schedule_config, _NOW)
# 构造创建后数据库返回的行
created_row = _make_db_row(
schedule_id="test-sched-id",
site_id=site_id,
name=name,
task_codes=task_codes,
task_config=task_config,
schedule_config=schedule_config_dict,
enabled=schedule_config.enabled,
next_run_at=next_run,
)
# --- 创建阶段 ---
# mock POST 的数据库连接INSERT ... RETURNING
create_cursor = MagicMock()
create_cursor.fetchone.return_value = created_row
create_conn = MagicMock()
create_conn.cursor.return_value.__enter__ = MagicMock(return_value=create_cursor)
create_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
# --- 查询阶段 ---
# mock GET 的数据库连接SELECT ... fetchall
list_cursor = MagicMock()
list_cursor.fetchall.return_value = [created_row]
list_conn = MagicMock()
list_conn.cursor.return_value.__enter__ = MagicMock(return_value=list_cursor)
list_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
# 依次返回 create_conn 和 list_conn
mock_get_conn.side_effect = [create_conn, list_conn]
# 覆盖认证
test_user = CurrentUser(user_id=1, site_id=site_id)
app.dependency_overrides[get_current_user] = lambda: test_user
try:
client = TestClient(app)
# 创建调度任务
create_body = {
"name": name,
"task_codes": task_codes,
"task_config": task_config,
"schedule_config": schedule_config_dict,
}
create_resp = client.post("/api/schedules", json=create_body)
assert create_resp.status_code == 201, (
f"创建应返回 201实际 {create_resp.status_code}: {create_resp.text}"
)
created_data = create_resp.json()
# 查询调度任务列表
list_resp = client.get("/api/schedules")
assert list_resp.status_code == 200
list_data = list_resp.json()
assert len(list_data) >= 1, "查询结果应至少包含刚创建的任务"
# 找到刚创建的任务
found = next((s for s in list_data if s["id"] == created_data["id"]), None)
assert found is not None, "查询结果应包含刚创建的任务"
# 核心验证schedule_config 往返一致
returned_config = found["schedule_config"]
for key in schedule_config_dict:
assert returned_config[key] == schedule_config_dict[key], (
f"schedule_config.{key} 不一致:"
f"提交={schedule_config_dict[key]},返回={returned_config[key]}"
)
# 验证 task_config 往返一致
returned_task_config = found["task_config"]
for key in task_config:
assert returned_task_config[key] == task_config[key], (
f"task_config.{key} 不一致:提交={task_config[key]},返回={returned_task_config[key]}"
)
# 验证基本字段
assert found["name"] == name
assert found["task_codes"] == task_codes
assert found["site_id"] == site_id
finally:
app.dependency_overrides[get_current_user] = lambda: CurrentUser(user_id=1, site_id=100)
# ---------------------------------------------------------------------------
# Feature: admin-web-console, Property 13: 到期调度任务自动入队
# **Validates: Requirements 5.2**
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
site_id=_site_id_st,
schedule_config=_schedule_config_st,
task_config=_simple_task_config_st,
)
@patch("app.services.scheduler.task_queue")
@patch("app.services.scheduler.get_connection")
def test_due_schedule_auto_enqueue(
mock_get_conn, mock_tq, site_id, schedule_config, task_config,
):
"""Property 13: 到期调度任务自动入队。
enabled 为 true 且 next_run_at 早于当前时间的调度任务,
check_and_enqueue 执行后该任务的 TaskConfig 应出现在执行队列中。
"""
sched = Scheduler()
schedule_config_dict = schedule_config.model_dump()
# 构造到期任务next_run_at 在过去(比 now 早 5 分钟)
task_id = "due-task-001"
# --- mock SELECT 到期任务 ---
select_cursor = MagicMock()
select_cursor.fetchall.return_value = [
(task_id, site_id, json.dumps(task_config), json.dumps(schedule_config_dict)),
]
select_cursor.__enter__ = MagicMock(return_value=select_cursor)
select_cursor.__exit__ = MagicMock(return_value=False)
# --- mock UPDATE 调度状态 ---
update_cursor = MagicMock()
update_cursor.__enter__ = MagicMock(return_value=update_cursor)
update_cursor.__exit__ = MagicMock(return_value=False)
conn = MagicMock()
conn.cursor.side_effect = [select_cursor, update_cursor]
mock_get_conn.return_value = conn
mock_tq.enqueue.return_value = "queue-id-123"
# 执行
count = sched.check_and_enqueue()
# 验证:到期任务被入队
assert count == 1, f"应有 1 个任务入队,实际 {count}"
mock_tq.enqueue.assert_called_once()
# 验证入队参数
call_args = mock_tq.enqueue.call_args
enqueued_config = call_args[0][0]
enqueued_site_id = call_args[0][1]
# site_id 应匹配
assert enqueued_site_id == site_id, (
f"入队的 site_id 应为 {site_id},实际 {enqueued_site_id}"
)
# TaskConfig 应与原始配置一致
assert isinstance(enqueued_config, TaskConfigSchema)
assert enqueued_config.tasks == task_config["tasks"], (
f"入队的 tasks 应为 {task_config['tasks']},实际 {enqueued_config.tasks}"
)
assert enqueued_config.flow == task_config["flow"], (
f"入队的 flow 应为 {task_config['flow']},实际 {enqueued_config.flow}"
)
# ---------------------------------------------------------------------------
# Feature: admin-web-console, Property 14: 调度任务启用/禁用状态
# **Validates: Requirements 5.3**
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(
site_id=_site_id_st,
schedule_config=_non_once_schedule_config_st,
task_config=_simple_task_config_st,
name=st.text(min_size=1, max_size=30, alphabet=st.characters(
whitelist_categories=("L", "N"), whitelist_characters="_- "
)),
task_codes=st.lists(st.sampled_from(_task_codes), min_size=1, max_size=3, unique=True),
)
@patch("app.routers.schedules.get_connection")
def test_schedule_toggle_next_run(
mock_get_conn, site_id, schedule_config, task_config, name, task_codes,
):
"""Property 14: 调度任务启用/禁用状态。
禁用后 next_run_at 应为 NULL
重新启用后 next_run_at 应被重新计算为非 NULL 值(对于非一次性调度)。
"""
schedule_config_dict = schedule_config.model_dump()
next_run_enabled = calculate_next_run(schedule_config, _NOW)
# --- 第一步禁用enabled=True → False---
# toggle 端点先 SELECT 当前状态,再 UPDATE RETURNING
# 禁用后的数据库行
disabled_row = _make_db_row(
schedule_id="sched-toggle-1",
site_id=site_id,
name=name,
task_codes=task_codes,
task_config=task_config,
schedule_config=schedule_config_dict,
enabled=False,
next_run_at=None, # 禁用后 next_run_at 为 NULL
)
# mock 禁用操作的数据库连接
disable_cursor = MagicMock()
disable_cursor.fetchone.side_effect = [
(True, json.dumps(schedule_config_dict)), # SELECT 当前状态enabled=True
disabled_row, # UPDATE RETURNING
]
disable_conn = MagicMock()
disable_conn.cursor.return_value.__enter__ = MagicMock(return_value=disable_cursor)
disable_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
# --- 第二步启用enabled=False → True---
enabled_row = _make_db_row(
schedule_id="sched-toggle-1",
site_id=site_id,
name=name,
task_codes=task_codes,
task_config=task_config,
schedule_config=schedule_config_dict,
enabled=True,
next_run_at=next_run_enabled, # 启用后 next_run_at 被重新计算
)
enable_cursor = MagicMock()
enable_cursor.fetchone.side_effect = [
(False, json.dumps(schedule_config_dict)), # SELECT 当前状态enabled=False
enabled_row, # UPDATE RETURNING
]
enable_conn = MagicMock()
enable_conn.cursor.return_value.__enter__ = MagicMock(return_value=enable_cursor)
enable_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
# 依次返回两个连接
mock_get_conn.side_effect = [disable_conn, enable_conn]
# 覆盖认证
test_user = CurrentUser(user_id=1, site_id=site_id)
app.dependency_overrides[get_current_user] = lambda: test_user
try:
client = TestClient(app)
# 禁用
disable_resp = client.patch("/api/schedules/sched-toggle-1/toggle")
assert disable_resp.status_code == 200, (
f"禁用应返回 200实际 {disable_resp.status_code}: {disable_resp.text}"
)
disable_data = disable_resp.json()
# 验证:禁用后 enabled=Falsenext_run_at=NULL
assert disable_data["enabled"] is False, "禁用后 enabled 应为 False"
assert disable_data["next_run_at"] is None, "禁用后 next_run_at 应为 NULL"
# 启用
enable_resp = client.patch("/api/schedules/sched-toggle-1/toggle")
assert enable_resp.status_code == 200, (
f"启用应返回 200实际 {enable_resp.status_code}: {enable_resp.text}"
)
enable_data = enable_resp.json()
# 验证:启用后 enabled=Truenext_run_at 非 NULL非一次性调度
assert enable_data["enabled"] is True, "启用后 enabled 应为 True"
assert enable_data["next_run_at"] is not None, (
"非一次性调度启用后 next_run_at 应被重新计算为非 NULL 值"
)
finally:
app.dependency_overrides[get_current_user] = lambda: CurrentUser(user_id=1, site_id=100)