"""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 services.test_case_assets import canonicalize_test_assets 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(), ) 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) ) definition = body.definition() try: await canonicalize_test_assets(session, definition) except ValueError as exc: raise HTTPException(422, str(exc)) from exc row = TestCase( id=_new_id("tc"), suite_id=suite_id, name=body.name.strip(), description=body.description.strip(), input_mode=body.input_mode, definition=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, "测试用例不存在") definition = body.definition() try: await canonicalize_test_assets(session, definition) except ValueError as exc: raise HTTPException(422, str(exc)) from exc row.name = body.name.strip() row.description = body.description.strip() row.input_mode = body.input_mode row.definition = definition.model_dump(mode="json", by_alias=True) 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)