370 lines
11 KiB
Python
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)
|