Files
ai-video-fullstack/backend/routes/test_cases.py
2026-08-10 13:49:24 +08:00

370 lines
11 KiB
Python

"""Persistent test suite and fixed-text test case CRUD."""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from db.models import TestCase, TestRunCase, TestSuite
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Query
from services.auth import require_admin
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
TestCaseBulkDeleteIn,
TestCaseDefinition,
TestCaseOrderIn,
TestCaseOut,
TestCaseWrite,
TestSuiteCreate,
TestSuiteOut,
TestSuiteUpdate,
)
router = APIRouter(
tags=["tests"],
dependencies=[Depends(require_admin)],
)
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
async def _touch_suite(session: AsyncSession, suite_id: str) -> None:
suite = await session.get(TestSuite, suite_id)
if suite is not None:
suite.updated_at = datetime.now(UTC)
async def _suite_out(session: AsyncSession, row: TestSuite) -> TestSuiteOut:
total = await session.scalar(
select(func.count()).select_from(TestCase).where(TestCase.suite_id == row.id)
)
run = await session.scalar(
select(func.count(func.distinct(TestRunCase.run_id))).where(
TestRunCase.suite_id == row.id
)
)
passed = await session.scalar(
select(func.count())
.select_from(TestCase)
.where(TestCase.suite_id == row.id, TestCase.last_result == "pass")
)
return TestSuiteOut(
id=row.id,
name=row.name,
description=row.description,
case_count=int(total or 0),
passed_count=int(passed or 0),
run_count=int(run or 0),
updated_at=row.updated_at,
)
def _case_out(row: TestCase) -> TestCaseOut:
definition = TestCaseDefinition.model_validate(row.definition or {})
return TestCaseOut(
id=row.id,
suite_id=row.suite_id,
name=row.name,
description=row.description,
input_mode=row.input_mode,
last_result=row.last_result,
sort_order=row.sort_order,
updated_at=row.updated_at,
**definition.model_dump(),
)
def _apply_case_write(row: TestCase, body: TestCaseWrite) -> None:
row.name = body.name.strip()
row.description = body.description.strip()
row.input_mode = body.input_mode
row.definition = body.definition().model_dump(mode="json", by_alias=True)
async def _renumber_cases(session: AsyncSession, suite_id: str) -> None:
rows = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order, TestCase.created_at, TestCase.id)
)
).scalars().all()
offset = len(rows) + 1
for index, row in enumerate(rows):
row.sort_order = offset + index
await session.flush()
for index, row in enumerate(rows):
row.sort_order = index
@router.get("/api/test-suites", response_model=list[TestSuiteOut])
async def list_test_suites(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(TestSuite).order_by(TestSuite.updated_at.desc()))
).scalars().all()
return [await _suite_out(session, row) for row in rows]
@router.post("/api/test-suites", response_model=TestSuiteOut)
async def create_test_suite(
body: TestSuiteCreate,
session: AsyncSession = Depends(get_session),
):
row = TestSuite(
id=_new_id("suite"),
name=body.name.strip(),
description=body.description.strip(),
)
session.add(row)
await session.commit()
await session.refresh(row)
return await _suite_out(session, row)
@router.get("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
async def get_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
return await _suite_out(session, row)
@router.put("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
async def update_test_suite(
suite_id: str,
body: TestSuiteUpdate,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
row.name = body.name.strip()
row.description = body.description.strip()
await session.commit()
await session.refresh(row)
return await _suite_out(session, row)
@router.delete("/api/test-suites/{suite_id}")
async def delete_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
await session.delete(row)
await session.commit()
return {"ok": True}
@router.post("/api/test-suites/{suite_id}/duplicate", response_model=TestSuiteOut)
async def duplicate_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
source = await session.get(TestSuite, suite_id)
if source is None:
raise HTTPException(404, "测试集不存在")
copied = TestSuite(
id=_new_id("suite"),
name=f"{source.name}(副本)",
description=source.description,
)
session.add(copied)
source_cases = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order)
)
).scalars().all()
for index, source_case in enumerate(source_cases):
session.add(
TestCase(
id=_new_id("tc"),
suite_id=copied.id,
name=source_case.name,
description=source_case.description,
input_mode=source_case.input_mode,
definition=dict(source_case.definition or {}),
sort_order=index,
last_result="not_run",
)
)
await session.commit()
await session.refresh(copied)
return await _suite_out(session, copied)
@router.get("/api/test-cases", response_model=list[TestCaseOut])
async def list_test_cases(
suite_id: str | None = Query(default=None, alias="suiteId"),
session: AsyncSession = Depends(get_session),
):
statement = select(TestCase)
if suite_id:
statement = statement.where(TestCase.suite_id == suite_id)
rows = (
await session.execute(
statement.order_by(TestCase.suite_id, TestCase.sort_order, TestCase.name)
)
).scalars().all()
return [_case_out(row) for row in rows]
@router.post("/api/test-suites/{suite_id}/cases", response_model=TestCaseOut)
async def create_test_case(
suite_id: str,
body: TestCaseWrite,
session: AsyncSession = Depends(get_session),
):
if await session.get(TestSuite, suite_id) is None:
raise HTTPException(404, "测试集不存在")
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id)
)
row = TestCase(
id=_new_id("tc"),
suite_id=suite_id,
name=body.name.strip(),
description=body.description.strip(),
input_mode=body.input_mode,
definition=body.definition().model_dump(mode="json", by_alias=True),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
session.add(row)
await _touch_suite(session, suite_id)
await session.commit()
await session.refresh(row)
return _case_out(row)
@router.post("/api/test-cases/bulk-delete")
async def bulk_delete_test_cases(
body: TestCaseBulkDeleteIn,
session: AsyncSession = Depends(get_session),
):
rows = (
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
).scalars().all()
suite_ids = {row.suite_id for row in rows}
for row in rows:
await session.delete(row)
await session.flush()
for suite_id in suite_ids:
await _renumber_cases(session, suite_id)
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True, "deleted": len(rows)}
@router.put("/api/test-suites/{suite_id}/case-order")
async def reorder_test_cases(
suite_id: str,
body: TestCaseOrderIn,
session: AsyncSession = Depends(get_session),
):
rows = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order)
)
).scalars().all()
if {row.id for row in rows} != set(body.case_ids) or len(rows) != len(body.case_ids):
raise HTTPException(422, "排序列表必须包含测试集内全部用例且不能重复")
by_id = {row.id: row for row in rows}
offset = len(rows) + 1
for index, row in enumerate(rows):
row.sort_order = offset + index
await session.flush()
for index, case_id in enumerate(body.case_ids):
by_id[case_id].sort_order = index
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True}
@router.get("/api/test-cases/{case_id}", response_model=TestCaseOut)
async def get_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
return _case_out(row)
@router.put("/api/test-cases/{case_id}", response_model=TestCaseOut)
async def update_test_case(
case_id: str,
body: TestCaseWrite,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
_apply_case_write(row, body)
await _touch_suite(session, row.suite_id)
await session.commit()
await session.refresh(row)
return _case_out(row)
@router.delete("/api/test-cases/{case_id}")
async def delete_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
suite_id = row.suite_id
await session.delete(row)
await session.flush()
await _renumber_cases(session, suite_id)
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True}
@router.post("/api/test-cases/{case_id}/duplicate", response_model=TestCaseOut)
async def duplicate_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
source = await session.get(TestCase, case_id)
if source is None:
raise HTTPException(404, "测试用例不存在")
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(
TestCase.suite_id == source.suite_id
)
)
copied = TestCase(
id=_new_id("tc"),
suite_id=source.suite_id,
name=f"{source.name}(副本)",
description=source.description,
input_mode=source.input_mode,
definition=dict(source.definition or {}),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
session.add(copied)
await _touch_suite(session, source.suite_id)
await session.commit()
await session.refresh(copied)
return _case_out(copied)