Files
Neo-ZQYY/apps/backend/tests/test_schedules_router.py

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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