zioinfo-mail/itsm/routers/si_tests.py
DESKTOP-TKLFCPR\ython e228faabf5 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

507 lines
19 KiB
Python

"""
테스트 관리 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