在准备环境前提交次全部更改。
This commit is contained in:
439
apps/backend/tests/test_schedule_properties.py
Normal file
439
apps/backend/tests/test_schedule_properties.py
Normal file
@@ -0,0 +1,439 @@
|
||||
# -*- 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),
|
||||
"pipeline": 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.pipeline == task_config["pipeline"], (
|
||||
f"入队的 pipeline 应为 {task_config['pipeline']},实际 {enqueued_config.pipeline}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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=False,next_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=True,next_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)
|
||||
Reference in New Issue
Block a user