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

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,310 @@
# -*- 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