# -*- coding: utf-8 -*- """调度路由单元测试 覆盖 5 个端点:list / create / update / delete / toggle 通过 mock 绕过数据库,专注路由逻辑验证。 """ import os os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-unit-tests") import json from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from app.auth.dependencies import CurrentUser, get_current_user from app.main import app _TEST_USER = CurrentUser(user_id=1, site_id=100) def _override_auth(): return _TEST_USER app.dependency_overrides[get_current_user] = _override_auth client = TestClient(app) _NOW = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc) _NEXT = datetime(2024, 6, 2, 4, 0, 0, tzinfo=timezone.utc) _SCHEDULE_CONFIG = { "schedule_type": "daily", "daily_time": "04:00", } _VALID_CREATE = { "name": "每日全量同步", "task_codes": ["ODS_MEMBER", "ODS_ORDER"], "task_config": {"tasks": ["ODS_MEMBER", "ODS_ORDER"], "pipeline": "api_ods"}, "schedule_config": _SCHEDULE_CONFIG, } # 模拟数据库返回的完整行(13 列,与 _SELECT_COLS 对应) _DB_ROW = ( "sched-1", 100, "每日全量同步", ["ODS_MEMBER", "ODS_ORDER"], json.dumps({"tasks": ["ODS_MEMBER"], "pipeline": "api_ods"}), json.dumps(_SCHEDULE_CONFIG), True, None, _NEXT, 0, None, _NOW, _NOW, ) def _mock_conn_with_fetchall(rows): """构造返回 fetchall 的 mock 连接。""" mock_cursor = MagicMock() mock_cursor.fetchall.return_value = rows mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) return mock_conn, mock_cursor def _mock_conn_with_fetchone(row): """构造返回 fetchone 的 mock 连接。""" mock_cursor = MagicMock() mock_cursor.fetchone.return_value = row mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) return mock_conn, mock_cursor # --------------------------------------------------------------------------- # GET /api/schedules # --------------------------------------------------------------------------- class TestListSchedules: @patch("app.routers.schedules.get_connection") def test_list_returns_schedules(self, mock_get_conn): mock_conn, _ = _mock_conn_with_fetchall([_DB_ROW]) mock_get_conn.return_value = mock_conn resp = client.get("/api/schedules") assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["id"] == "sched-1" assert data[0]["name"] == "每日全量同步" assert data[0]["site_id"] == 100 assert data[0]["enabled"] is True @patch("app.routers.schedules.get_connection") def test_list_empty(self, mock_get_conn): mock_conn, _ = _mock_conn_with_fetchall([]) mock_get_conn.return_value = mock_conn resp = client.get("/api/schedules") assert resp.status_code == 200 assert resp.json() == [] @patch("app.routers.schedules.get_connection") def test_list_filters_by_site_id(self, mock_get_conn): mock_conn, mock_cursor = _mock_conn_with_fetchall([]) mock_get_conn.return_value = mock_conn client.get("/api/schedules") call_args = mock_cursor.execute.call_args assert call_args[0][1] == (100,) # --------------------------------------------------------------------------- # POST /api/schedules # --------------------------------------------------------------------------- class TestCreateSchedule: @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT) @patch("app.routers.schedules.get_connection") def test_create_returns_201(self, mock_get_conn, mock_calc): mock_conn, mock_cursor = _mock_conn_with_fetchone(_DB_ROW) mock_get_conn.return_value = mock_conn resp = client.post("/api/schedules", json=_VALID_CREATE) assert resp.status_code == 201 data = resp.json() assert data["id"] == "sched-1" assert data["name"] == "每日全量同步" @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT) @patch("app.routers.schedules.get_connection") def test_create_injects_site_id(self, mock_get_conn, mock_calc): mock_conn, mock_cursor = _mock_conn_with_fetchone(_DB_ROW) mock_get_conn.return_value = mock_conn client.post("/api/schedules", json=_VALID_CREATE) # INSERT 的第一个参数应为 site_id=100 insert_params = mock_cursor.execute.call_args[0][1] assert insert_params[0] == 100 def test_create_missing_name_returns_422(self): body = {**_VALID_CREATE} del body["name"] resp = client.post("/api/schedules", json=body) assert resp.status_code == 422 def test_create_invalid_schedule_type_returns_422(self): body = {**_VALID_CREATE, "schedule_config": {"schedule_type": "invalid"}} resp = client.post("/api/schedules", json=body) assert resp.status_code == 422 # --------------------------------------------------------------------------- # PUT /api/schedules/{id} # --------------------------------------------------------------------------- class TestUpdateSchedule: @patch("app.routers.schedules.get_connection") def test_update_name(self, mock_get_conn): updated_row = list(_DB_ROW) updated_row[2] = "新名称" mock_conn, _ = _mock_conn_with_fetchone(tuple(updated_row)) mock_get_conn.return_value = mock_conn resp = client.put("/api/schedules/sched-1", json={"name": "新名称"}) assert resp.status_code == 200 assert resp.json()["name"] == "新名称" @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT) @patch("app.routers.schedules.get_connection") def test_update_schedule_config_recalculates_next_run(self, mock_get_conn, mock_calc): mock_conn, _ = _mock_conn_with_fetchone(_DB_ROW) mock_get_conn.return_value = mock_conn resp = client.put("/api/schedules/sched-1", json={ "schedule_config": {"schedule_type": "interval", "interval_value": 2, "interval_unit": "hours"}, }) assert resp.status_code == 200 mock_calc.assert_called_once() @patch("app.routers.schedules.get_connection") def test_update_not_found(self, mock_get_conn): mock_conn, _ = _mock_conn_with_fetchone(None) mock_get_conn.return_value = mock_conn resp = client.put("/api/schedules/nonexistent", json={"name": "x"}) assert resp.status_code == 404 def test_update_empty_body_returns_422(self): resp = client.put("/api/schedules/sched-1", json={}) assert resp.status_code == 422 # --------------------------------------------------------------------------- # DELETE /api/schedules/{id} # --------------------------------------------------------------------------- class TestDeleteSchedule: @patch("app.routers.schedules.get_connection") def test_delete_success(self, mock_get_conn): mock_cursor = MagicMock() mock_cursor.rowcount = 1 mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_get_conn.return_value = mock_conn resp = client.delete("/api/schedules/sched-1") assert resp.status_code == 200 assert "已删除" in resp.json()["message"] @patch("app.routers.schedules.get_connection") def test_delete_not_found(self, mock_get_conn): mock_cursor = MagicMock() mock_cursor.rowcount = 0 mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_get_conn.return_value = mock_conn resp = client.delete("/api/schedules/nonexistent") assert resp.status_code == 404 # --------------------------------------------------------------------------- # PATCH /api/schedules/{id}/toggle # --------------------------------------------------------------------------- class TestToggleSchedule: @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT) @patch("app.routers.schedules.get_connection") def test_toggle_disable(self, mock_get_conn, mock_calc): """启用 → 禁用:next_run_at 应置 NULL""" # 第一次 fetchone 返回当前状态(enabled=True) # 第二次 fetchone 返回更新后的行 disabled_row = list(_DB_ROW) disabled_row[6] = False # enabled disabled_row[8] = None # next_run_at mock_cursor = MagicMock() mock_cursor.fetchone.side_effect = [ (True, json.dumps(_SCHEDULE_CONFIG)), # SELECT 当前状态 tuple(disabled_row), # UPDATE RETURNING ] mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_get_conn.return_value = mock_conn resp = client.patch("/api/schedules/sched-1/toggle") assert resp.status_code == 200 data = resp.json() assert data["enabled"] is False assert data["next_run_at"] is None @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT) @patch("app.routers.schedules.get_connection") def test_toggle_enable(self, mock_get_conn, mock_calc): """禁用 → 启用:next_run_at 应被重新计算""" enabled_row = list(_DB_ROW) enabled_row[6] = True enabled_row[8] = _NEXT mock_cursor = MagicMock() mock_cursor.fetchone.side_effect = [ (False, json.dumps(_SCHEDULE_CONFIG)), # SELECT 当前状态(disabled) tuple(enabled_row), # UPDATE RETURNING ] mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_get_conn.return_value = mock_conn resp = client.patch("/api/schedules/sched-1/toggle") assert resp.status_code == 200 data = resp.json() assert data["enabled"] is True assert data["next_run_at"] is not None mock_calc.assert_called_once() @patch("app.routers.schedules.get_connection") def test_toggle_not_found(self, mock_get_conn): mock_cursor = MagicMock() mock_cursor.fetchone.return_value = None mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) mock_get_conn.return_value = mock_conn resp = client.patch("/api/schedules/nonexistent/toggle") assert resp.status_code == 404 # --------------------------------------------------------------------------- # 认证测试 # --------------------------------------------------------------------------- class TestSchedulesAuth: def test_requires_auth(self): """移除认证覆盖后,所有端点应返回 401/403""" app.dependency_overrides.pop(get_current_user, None) try: assert client.get("/api/schedules").status_code in (401, 403) assert client.post("/api/schedules", json=_VALID_CREATE).status_code in (401, 403) assert client.put("/api/schedules/x", json={"name": "x"}).status_code in (401, 403) assert client.delete("/api/schedules/x").status_code in (401, 403) assert client.patch("/api/schedules/x/toggle").status_code in (401, 403) finally: app.dependency_overrides[get_current_user] = _override_auth