311 lines
12 KiB
Python
311 lines
12 KiB
Python
# -*- 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
|