在准备环境前提交次全部更改。

This commit is contained in:
Neo
2026-02-19 08:35:13 +08:00
parent ded6dfb9d8
commit 4eac07da47
1387 changed files with 6107191 additions and 33002 deletions

View 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=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)