1. 为所有查询参数添加max_length长度限制,规范参数输入范围 2. 使用Literal类型替换普通字符串参数,限定合法取值范围 3. 为路径参数添加Path校验,确保ID参数合法有效 4. 优化请求体参数的声明,补充缺失的Body注解和校验规则 5. 统一分页参数的offset/limit使用方式,替换旧的page/page_size模式
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""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)
|
||
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)
|
||
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),
|
||
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(
|
||
page=offset // limit + 1,
|
||
page_size=limit,
|
||
tool_slug=tool_slug,
|
||
env_key=env_key,
|
||
enabled=enabled,
|
||
schedule_cron=schedule_cron,
|
||
)
|
||
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),
|
||
env_key: str | None = Query(None),
|
||
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()}
|