# -*- 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=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)