1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
391 lines
15 KiB
Python
391 lines
15 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, 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),
|
||
expected_status: str | None = Query(None, max_length=32),
|
||
keyword: str | None = Query(None, max_length=128),
|
||
sort_by: str = Query("created_at", max_length=32),
|
||
sort_order: str = Query("desc", pattern=r"^(asc|desc)$"),
|
||
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,
|
||
expected_status=expected_status,
|
||
keyword=keyword,
|
||
sort_by=sort_by,
|
||
sort_order=sort_order,
|
||
)
|
||
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]:
|
||
"""删除测试用例。``deleted_by`` 由当前登录用户填充。"""
|
||
use_cases = create_use_cases_from_db(db)
|
||
input_dto = DeleteTestCaseInput(id=case_id, deleted_by=current_user.uid)
|
||
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()}
|