ForcePilot/backend/server/routers/external_systems/test_case_router.py
Kris 6dd16ad3f8 refactor(external-systems-routers): 统一分页参数格式并完善各路由文档与校验
本次提交对多个外部系统路由进行了多维度优化:
1.  统一分页参数:将所有路由的`page = offset//limit +1`、`page_size=limit`替换为标准的`limit`+`offset`分页格式
2.  完善接口文档:补充多个端点的功能说明、参数含义与返回字段解释
3.  增强参数校验:新增字段长度限制、正则校验、枚举类型约束与业务逻辑校验
4.  优化代码复用:提取重复逻辑为辅助函数,减少样板代码
5.  修复接口问题:修正工具健康检查端点路径参数类型,优化导出接口响应格式
6.  补充异常处理:为批量操作添加异常捕获与日志记录,避免流程中断
2026-07-11 06:57:24 +08:00

385 lines
14 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.

"""TestCase 子域 Router。
外部系统限界上下文的工具回归测试用例管理 API覆盖测试用例 CRUD、执行管理、
克隆、导入导出、告警配置管理。所有端点通过 ``create_use_cases_from_db`` 装配
use_cases经 ``test_case_service`` 端口调用用例。
Request Schema 与 Input DTO 不共享类Router 内显式构造 DTO操作人字段由
current_user.uid 填充。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Body, Depends, Path, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
from yuxi.external_systems.use_cases.dto.test_case import (
BatchCloneTestCasesInput,
BatchExecuteTestCasesInput,
CloneTestCaseInput,
CreateTestCaseInput,
DeleteTestCaseInput,
ExportTestCasesInput,
GetAlertConfigInput,
GetTestCaseInput,
ImportTestCasesInput,
ListTestCasesInput,
RunTestCaseInput,
UpdateAlertConfigInput,
UpdateTestCaseInput,
)
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
test_case_router = APIRouter(prefix="/test-cases", tags=["external-systems-test-case"])
# ---------------- Request Schemas ----------------
class CreateTestCaseRequest(BaseModel):
"""创建测试用例请求体。字段对齐 ``CreateTestCaseInput``(不含 created_by
字段长度约束对齐 ``ExternalToolTestCase`` ORM 列定义,在边界层拦截非法输入:
``tool_slug`` 对齐 String(128) 且与工具子域 slug 规则一致、
``name`` 对齐 String(128)、``env_key``/``expected_status`` 对齐 String(32)、
``schedule_cron`` 对齐 String(64)、``timeout`` 对齐工具子域取值范围。
"""
model_config = ConfigDict(frozen=True)
tool_slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$")
name: str = Field(..., min_length=1, max_length=128)
request_fixture: dict[str, Any]
description: str | None = None
env_key: str | None = Field(default=None, max_length=32)
expected_status: str | None = Field(default=None, max_length=32)
assertions: list[dict[str, Any]] = Field(default_factory=list)
timeout: int = Field(default=30, ge=1, le=300)
enabled: bool = True
schedule_cron: str | None = Field(default=None, max_length=64)
alert_on_failure: bool = True
alert_threshold: int = Field(default=3, ge=1)
class UpdateTestCaseRequest(BaseModel):
"""更新测试用例请求体。字段对齐 ``UpdateTestCaseInput``(不含 id 与 updated_by
字段长度约束对齐 ``ExternalToolTestCase`` ORM 列定义,仅透传客户端显式设置的字段
(通过 ``exclude_unset=True``),未设置字段保持 ``None`` 以保留部分更新语义。
"""
model_config = ConfigDict(frozen=True)
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = None
env_key: str | None = Field(default=None, max_length=32)
expected_status: str | None = Field(default=None, max_length=32)
request_fixture: dict[str, Any] | None = None
assertions: list[dict[str, Any]] | None = None
timeout: int | None = Field(default=None, ge=1, le=300)
enabled: bool | None = None
schedule_cron: str | None = Field(default=None, max_length=64)
alert_on_failure: bool | None = None
alert_threshold: int | None = Field(default=None, ge=1)
class BatchExecuteTestCasesRequest(BaseModel):
"""批量执行测试用例请求体。字段对齐 ``BatchExecuteTestCasesInput``(不含 triggered_by"""
model_config = ConfigDict(frozen=True)
case_ids: list[int] = Field(..., min_length=1, max_length=100)
stop_on_failure: bool = False
class CloneTestCaseRequest(BaseModel):
"""克隆测试用例请求体。字段对齐 ``CloneTestCaseInput``(不含 case_id 与 created_by"""
model_config = ConfigDict(frozen=True)
new_name: str = Field(..., min_length=1, max_length=128)
class BatchCloneTestCasesRequest(BaseModel):
"""批量克隆测试用例请求体。字段对齐 ``BatchCloneTestCasesInput``(不含 created_by"""
model_config = ConfigDict(frozen=True)
source_case_ids: list[int] = Field(..., min_length=1, max_length=100)
name_suffix: str = "_copy"
override_config: dict[str, Any] = Field(default_factory=dict)
class ImportTestCasesRequest(BaseModel):
"""导入测试用例请求体。"""
model_config = ConfigDict(frozen=True)
items: list[CreateTestCaseRequest] = Field(..., min_length=1, max_length=100)
class UpdateAlertConfigRequest(BaseModel):
"""更新告警配置请求体。字段对齐 ``UpdateAlertConfigInput``(不含 case_id 与 updated_by"""
model_config = ConfigDict(frozen=True)
alert_on_failure: bool
alert_threshold: int = Field(ge=1)
# ---------------- Endpoints ----------------
@test_case_router.get("", response_model=dict)
async def list_test_cases(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
tool_slug: str | None = Query(None, max_length=128),
env_key: str | None = Query(None, max_length=32),
enabled: bool | None = Query(None),
schedule_cron: str | None = Query(None, max_length=64),
last_run_status: str | None = Query(None, max_length=32),
keyword: str | None = Query(None, max_length=128),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出测试用例,支持按工具/环境/启用状态/调度表达式/上次运行状态过滤及关键词搜索。"""
use_cases = create_use_cases_from_db(db)
input_dto = ListTestCasesInput(
limit=limit,
offset=offset,
tool_slug=tool_slug,
env_key=env_key,
enabled=enabled,
schedule_cron=schedule_cron,
last_run_status=last_run_status,
keyword=keyword,
)
output = await use_cases.test_case_service.list_test_cases(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.post("", response_model=dict)
async def create_test_case(
payload: CreateTestCaseRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建测试用例。``created_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = CreateTestCaseInput(
created_by=current_user.uid,
**payload.model_dump(),
)
output = await use_cases.test_case_service.create_test_case(input_dto)
return {"success": True, "data": output.model_dump()}
# ---------------- 静态路径端点(必须在 /{case_id} 之前声明) ----------------
@test_case_router.post("/batch-execute", response_model=dict)
async def batch_execute_test_cases(
payload: BatchExecuteTestCasesRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""批量执行测试用例。``triggered_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = BatchExecuteTestCasesInput(
case_ids=payload.case_ids,
stop_on_failure=payload.stop_on_failure,
triggered_by=current_user.uid,
)
output = await use_cases.test_case_service.batch_execute_test_cases(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.post("/batch-clone", response_model=dict)
async def batch_clone_test_cases(
payload: BatchCloneTestCasesRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量克隆测试用例。``created_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = BatchCloneTestCasesInput(
source_case_ids=payload.source_case_ids,
name_suffix=payload.name_suffix,
override_config=payload.override_config,
created_by=current_user.uid,
)
output = await use_cases.test_case_service.batch_clone_test_cases(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.post("/import", response_model=dict)
async def import_test_cases(
payload: ImportTestCasesRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""导入测试用例JSON body。``created_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = ImportTestCasesInput(
items=[CreateTestCaseInput(**item.model_dump(), created_by=current_user.uid) for item in payload.items],
created_by=current_user.uid,
)
output = await use_cases.test_case_service.import_test_cases(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.get("/export")
async def export_test_cases(
tool_slug: str | None = Query(None, max_length=128),
env_key: str | None = Query(None, max_length=32),
enabled: bool | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> StreamingResponse:
"""导出测试用例为 JSON 文件。对齐 ``system_router.export_systems`` 模式。"""
use_cases = create_use_cases_from_db(db)
input_dto = ExportTestCasesInput(
tool_slug=tool_slug,
env_key=env_key,
enabled=enabled,
)
output = await use_cases.test_case_service.export_test_cases(input_dto)
payload = json.dumps(
[item.model_dump() for item in output.items],
ensure_ascii=False,
default=str,
).encode("utf-8")
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
async def _stream():
yield payload
return StreamingResponse(
_stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename=test_cases_{timestamp}.json"},
)
@test_case_router.get("/{case_id}", response_model=dict)
async def get_test_case(
case_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取测试用例详情。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetTestCaseInput(id=case_id)
output = await use_cases.test_case_service.get_test_case(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.put("/{case_id}", response_model=dict)
async def update_test_case(
case_id: int = Path(ge=1),
payload: UpdateTestCaseRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新测试用例。``updated_by`` 由当前登录用户填充。仅透传显式设置的字段。"""
use_cases = create_use_cases_from_db(db)
input_dto = UpdateTestCaseInput(
id=case_id,
updated_by=current_user.uid,
**payload.model_dump(exclude_unset=True),
)
output = await use_cases.test_case_service.update_test_case(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.delete("/{case_id}", response_model=dict)
async def delete_test_case(
case_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除测试用例。"""
use_cases = create_use_cases_from_db(db)
input_dto = DeleteTestCaseInput(id=case_id)
output = await use_cases.test_case_service.delete_test_case(input_dto)
return {"success": True, "data": output.model_dump()}
# ---------------- 动态路径端点 /{case_id}/* ----------------
@test_case_router.post("/{case_id}/run", response_model=dict)
async def run_test_case(
case_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""执行单个测试用例。``triggered_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = RunTestCaseInput(
case_id=case_id,
triggered_by=current_user.uid,
)
output = await use_cases.test_case_service.run_test_case(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.post("/{case_id}/clone", response_model=dict)
async def clone_test_case(
case_id: int = Path(ge=1),
payload: CloneTestCaseRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""克隆测试用例。``created_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = CloneTestCaseInput(
case_id=case_id,
new_name=payload.new_name,
created_by=current_user.uid,
)
output = await use_cases.test_case_service.clone_test_case(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.get("/{case_id}/alert-config", response_model=dict)
async def get_alert_config(
case_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询测试用例告警配置。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetAlertConfigInput(case_id=case_id)
output = await use_cases.test_case_service.get_alert_config(input_dto)
return {"success": True, "data": output.model_dump()}
@test_case_router.put("/{case_id}/alert-config", response_model=dict)
async def update_alert_config(
case_id: int = Path(ge=1),
payload: UpdateAlertConfigRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新测试用例告警配置。``updated_by`` 由当前登录用户填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = UpdateAlertConfigInput(
case_id=case_id,
alert_on_failure=payload.alert_on_failure,
alert_threshold=payload.alert_threshold,
updated_by=current_user.uid,
)
output = await use_cases.test_case_service.update_alert_config(input_dto)
return {"success": True, "data": output.model_dump()}