""" 테스트 관리 API — 단위/통합/UAT 테스트 계획·케이스·실행·결함. 엔드포인트: Plans: GET /api/si/projects/{pid}/test-plans — 테스트 계획 목록 POST /api/si/projects/{pid}/test-plans — 테스트 계획 등록 GET /api/si/projects/{pid}/test-plans/{id} — 상세 PATCH /api/si/projects/{pid}/test-plans/{id} — 수정 DELETE /api/si/projects/{pid}/test-plans/{id} — 삭제 Cases: GET /api/si/projects/{pid}/test-plans/{pid2}/cases — 케이스 목록 POST /api/si/projects/{pid}/test-plans/{pid2}/cases — 케이스 등록 PATCH /api/si/projects/{pid}/test-plans/{pid2}/cases/{id} — 케이스 수정 DELETE /api/si/projects/{pid}/test-plans/{pid2}/cases/{id} — 케이스 삭제 Executions: GET /api/si/test-cases/{id}/executions — 실행 이력 POST /api/si/test-cases/{id}/execute — 테스트 실행 (결과 입력) Defects: GET /api/si/projects/{pid}/defects — 결함 목록 POST /api/si/projects/{pid}/defects — 결함 등록 GET /api/si/projects/{pid}/defects/{id} — 결함 상세 PATCH /api/si/projects/{pid}/defects/{id} — 결함 수정 PATCH /api/si/projects/{pid}/defects/{id}/fix — 수정 완료 PATCH /api/si/projects/{pid}/defects/{id}/verify — 검증 완료 GET /api/si/projects/{pid}/defects/stats — 결함 통계 """ from __future__ import annotations import logging from datetime import datetime from typing import List, Optional from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( SiProject, SiTestPlan, SiTestPlanCreate, SiTestPlanOut, SiTestCase, SiTestCaseCreate, SiTestCaseOut, SiTestExecution, SiTestExecutionCreate, SiTestExecutionOut, SiDefect, SiDefectCreate, SiDefectOut, SiDefectUpdate, SiTestResult, DefectSeverity, DefectStatus, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/si", tags=["si-tests"]) def _new_tc_id() -> str: return f"TC-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}" def _new_defect_id() -> str: return f"DEF-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}" # ═══════════════════════════════════════════════════════════════════════════════ # 테스트 계획 # ═══════════════════════════════════════════════════════════════════════════════ @router.get("/projects/{project_id}/test-plans", response_model=List[SiTestPlanOut]) async def list_test_plans( project_id: int, test_type: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) q = select(SiTestPlan).where(SiTestPlan.project_id == project_id) if test_type: q = q.where(SiTestPlan.test_type == test_type) q = q.order_by(SiTestPlan.planned_start) return (await db.execute(q)).scalars().all() @router.post("/projects/{project_id}/test-plans", response_model=SiTestPlanOut, status_code=201) async def create_test_plan( project_id: int, body: SiTestPlanCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "엔지니어 이상 권한 필요") await _assert_project(project_id, db) plan = SiTestPlan( project_id = project_id, phase = body.phase, test_type = body.test_type, plan_name = body.plan_name, description = body.description, planned_start = body.planned_start, planned_end = body.planned_end, created_by = body.created_by or current_user.username, ) db.add(plan) await db.commit() await db.refresh(plan) return plan @router.get("/projects/{project_id}/test-plans/{plan_id}", response_model=SiTestPlanOut) async def get_test_plan( project_id: int, plan_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return await _get_plan_or_404(project_id, plan_id, db) @router.patch("/projects/{project_id}/test-plans/{plan_id}", response_model=SiTestPlanOut) async def update_test_plan( project_id: int, plan_id: int, actual_start: Optional[datetime] = Query(None), actual_end: Optional[datetime] = Query(None), is_completed: Optional[bool] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): plan = await _get_plan_or_404(project_id, plan_id, db) if actual_start: plan.actual_start = actual_start if actual_end: plan.actual_end = actual_end if is_completed is not None: plan.is_completed = is_completed await db.commit() await db.refresh(plan) return plan @router.delete("/projects/{project_id}/test-plans/{plan_id}", status_code=204) async def delete_test_plan( project_id: int, plan_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM 이상 권한 필요") plan = await _get_plan_or_404(project_id, plan_id, db) await db.delete(plan) await db.commit() # ═══════════════════════════════════════════════════════════════════════════════ # 테스트 케이스 # ═══════════════════════════════════════════════════════════════════════════════ @router.get("/projects/{project_id}/test-plans/{plan_id}/cases", response_model=List[SiTestCaseOut]) async def list_test_cases( project_id: int, plan_id: int, last_result: Optional[str] = Query(None), assigned_to: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _get_plan_or_404(project_id, plan_id, db) q = select(SiTestCase).where(SiTestCase.plan_id == plan_id) if last_result: q = q.where(SiTestCase.last_result == last_result) if assigned_to: q = q.where(SiTestCase.assigned_to == assigned_to) q = q.order_by(SiTestCase.tc_id) return (await db.execute(q)).scalars().all() @router.post("/projects/{project_id}/test-plans/{plan_id}/cases", response_model=SiTestCaseOut, status_code=201) async def create_test_case( project_id: int, plan_id: int, body: SiTestCaseCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): plan = await _get_plan_or_404(project_id, plan_id, db) tc = SiTestCase( tc_id = _new_tc_id(), plan_id = plan_id, req_id = body.req_id, title = body.title, precondition = body.precondition, test_steps = body.test_steps, expected_result = body.expected_result, assigned_to = body.assigned_to, priority = body.priority, ) db.add(tc) await db.flush() # 계획 TC 수 업데이트 plan.total_cases += 1 await db.commit() await db.refresh(tc) return tc @router.patch("/projects/{project_id}/test-plans/{plan_id}/cases/{tc_id}", response_model=SiTestCaseOut) async def update_test_case( project_id: int, plan_id: int, tc_id: int, body: SiTestCaseCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tc = await _get_tc_or_404(plan_id, tc_id, db) for field, val in body.model_dump(exclude_none=True).items(): setattr(tc, field, val) await db.commit() await db.refresh(tc) return tc @router.delete("/projects/{project_id}/test-plans/{plan_id}/cases/{tc_id}", status_code=204) async def delete_test_case( project_id: int, plan_id: int, tc_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): plan = await _get_plan_or_404(project_id, plan_id, db) tc = await _get_tc_or_404(plan_id, tc_id, db) await db.delete(tc) plan.total_cases = max(0, plan.total_cases - 1) await db.commit() # ═══════════════════════════════════════════════════════════════════════════════ # 테스트 실행 # ═══════════════════════════════════════════════════════════════════════════════ @router.get("/test-cases/{tc_id}/executions", response_model=List[SiTestExecutionOut]) async def list_executions( tc_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( select(SiTestExecution).where(SiTestExecution.tc_id == tc_id) .order_by(desc(SiTestExecution.executed_at)) ) return result.scalars().all() @router.post("/test-cases/{tc_id}/execute", response_model=SiTestExecutionOut, status_code=201) async def execute_test_case( tc_id: int, body: SiTestExecutionCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """테스트 실행 결과 기록 — TC 최종 결과 및 계획 통계 자동 갱신.""" tc = (await db.execute(select(SiTestCase).where(SiTestCase.id == tc_id))).scalars().first() if not tc: raise HTTPException(404, "테스트 케이스를 찾을 수 없습니다") exec_record = SiTestExecution( tc_id = tc_id, executed_by = body.executed_by or current_user.username, result = body.result, actual_result = body.actual_result, note = body.note, build_version = body.build_version, ) db.add(exec_record) # TC 최종 결과 갱신 prev_result = tc.last_result tc.last_result = body.result # 테스트 계획 통계 재계산 plan = (await db.execute( select(SiTestPlan).where(SiTestPlan.id == tc.plan_id) )).scalars().first() if plan: await _recalc_plan_stats(plan, db) await db.commit() await db.refresh(exec_record) return exec_record # ═══════════════════════════════════════════════════════════════════════════════ # 결함 관리 # ═══════════════════════════════════════════════════════════════════════════════ @router.get("/projects/{project_id}/defects", response_model=List[SiDefectOut]) async def list_defects( project_id: int, severity: Optional[str] = Query(None), status: Optional[str] = Query(None), assigned_to: Optional[str] = Query(None), phase: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) q = select(SiDefect).where(SiDefect.project_id == project_id) if severity: q = q.where(SiDefect.severity == severity) if status: q = q.where(SiDefect.status == status) if assigned_to: q = q.where(SiDefect.assigned_to == assigned_to) if phase: q = q.where(SiDefect.phase == phase) q = q.order_by(desc(SiDefect.created_at)) return (await db.execute(q)).scalars().all() @router.post("/projects/{project_id}/defects", response_model=SiDefectOut, status_code=201) async def create_defect( project_id: int, body: SiDefectCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) defect = SiDefect( defect_id = _new_defect_id(), project_id = project_id, tc_id = body.tc_id, req_id = body.req_id, title = body.title, description = body.description, severity = body.severity, phase = body.phase, reported_by = body.reported_by or current_user.username, assigned_to = body.assigned_to, build_found = body.build_found, note = body.note, ) db.add(defect) await db.commit() await db.refresh(defect) return defect @router.get("/projects/{project_id}/defects/{defect_id}", response_model=SiDefectOut) async def get_defect( project_id: int, defect_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return await _get_defect_or_404(project_id, defect_id, db) @router.patch("/projects/{project_id}/defects/{defect_id}", response_model=SiDefectOut) async def update_defect( project_id: int, defect_id: int, body: SiDefectUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): defect = await _get_defect_or_404(project_id, defect_id, db) for field, val in body.model_dump(exclude_none=True).items(): setattr(defect, field, val) await db.commit() await db.refresh(defect) return defect @router.patch("/projects/{project_id}/defects/{defect_id}/fix", response_model=SiDefectOut) async def fix_defect( project_id: int, defect_id: int, build_fixed: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): defect = await _get_defect_or_404(project_id, defect_id, db) defect.status = DefectStatus.FIXED defect.fixed_by = current_user.username defect.fixed_at = datetime.now() if build_fixed: defect.build_fixed = build_fixed await db.commit() await db.refresh(defect) return defect @router.patch("/projects/{project_id}/defects/{defect_id}/verify", response_model=SiDefectOut) async def verify_defect( project_id: int, defect_id: int, is_verified: bool = Query(True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """결함 검증 — 통과 시 CLOSED, 재현 시 OPEN으로 복귀.""" defect = await _get_defect_or_404(project_id, defect_id, db) if defect.status != DefectStatus.FIXED: raise HTTPException(400, "수정 완료 상태의 결함만 검증할 수 있습니다") if is_verified: defect.status = DefectStatus.VERIFIED defect.verified_by = current_user.username defect.verified_at = datetime.now() else: defect.status = DefectStatus.OPEN await db.commit() await db.refresh(defect) return defect @router.get("/projects/{project_id}/defects/stats") async def get_defect_stats( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) defects = (await db.execute( select(SiDefect).where(SiDefect.project_id == project_id) )).scalars().all() by_severity = {} by_status = {} by_phase = {} for d in defects: by_severity[d.severity] = by_severity.get(d.severity, 0) + 1 by_status[d.status] = by_status.get(d.status, 0) + 1 if d.phase: by_phase[d.phase] = by_phase.get(d.phase, 0) + 1 open_count = sum(1 for d in defects if d.status in ("OPEN", "ASSIGNED")) critical_count = sum(1 for d in defects if d.severity == "CRITICAL" and d.status not in ("CLOSED", "VERIFIED")) return { "project_id": project_id, "total": len(defects), "open": open_count, "critical_open": critical_count, "by_severity": by_severity, "by_status": by_status, "by_phase": by_phase, } # ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── async def _assert_project(project_id: int, db: AsyncSession) -> None: if not (await db.execute( select(SiProject).where(SiProject.id == project_id) )).scalars().first(): raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다") async def _get_plan_or_404(project_id: int, plan_id: int, db: AsyncSession) -> SiTestPlan: plan = (await db.execute( select(SiTestPlan).where( SiTestPlan.id == plan_id, SiTestPlan.project_id == project_id, ) )).scalars().first() if not plan: raise HTTPException(404, "테스트 계획을 찾을 수 없습니다") return plan async def _get_tc_or_404(plan_id: int, tc_id: int, db: AsyncSession) -> SiTestCase: tc = (await db.execute( select(SiTestCase).where( SiTestCase.id == tc_id, SiTestCase.plan_id == plan_id, ) )).scalars().first() if not tc: raise HTTPException(404, "테스트 케이스를 찾을 수 없습니다") return tc async def _get_defect_or_404(project_id: int, defect_id: int, db: AsyncSession) -> SiDefect: defect = (await db.execute( select(SiDefect).where( SiDefect.id == defect_id, SiDefect.project_id == project_id, ) )).scalars().first() if not defect: raise HTTPException(404, "결함을 찾을 수 없습니다") return defect async def _recalc_plan_stats(plan: SiTestPlan, db: AsyncSession) -> None: """테스트 계획 pass/fail/blocked/pass_rate 재계산.""" tcs = (await db.execute( select(SiTestCase).where(SiTestCase.plan_id == plan.id) )).scalars().all() plan.total_cases = len(tcs) plan.pass_count = sum(1 for t in tcs if t.last_result == SiTestResult.PASS) plan.fail_count = sum(1 for t in tcs if t.last_result == SiTestResult.FAIL) plan.blocked_count = sum(1 for t in tcs if t.last_result == SiTestResult.BLOCKED) plan.pass_rate = (plan.pass_count * 100 // plan.total_cases) if plan.total_cases else 0