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>
264 lines
9.7 KiB
Python
264 lines
9.7 KiB
Python
"""A-4 운영 이벤트 타임라인 테스트"""
|
|
import sys, ast, os, asyncio, json
|
|
from datetime import datetime, timedelta
|
|
|
|
os.environ.setdefault("GUARDIA_SECRET_KEY", "test-timeline-secret-key-32bytes!")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_timeline.db")
|
|
|
|
print("=== 1. 구문 검사 ===")
|
|
files = ["routers/timeline.py", "main.py"]
|
|
ok = True
|
|
for f in files:
|
|
try:
|
|
with open(f, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
ast.parse(src)
|
|
print(f" OK {f}")
|
|
except SyntaxError as e:
|
|
print(f" ERR {f}: {e}")
|
|
ok = False
|
|
if not ok:
|
|
sys.exit(1)
|
|
|
|
print("\n=== 2. 모듈 임포트 검사 ===")
|
|
try:
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location("timeline_mod", "routers/timeline.py")
|
|
timeline_mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(timeline_mod)
|
|
|
|
# EVENT_TYPES 확인
|
|
ev = timeline_mod.EVENT_TYPES
|
|
expected = {
|
|
"sr_created", "sr_status_changed", "sr_sla_violated", "sr_escalated",
|
|
"deploy_started", "deploy_completed", "deploy_failed",
|
|
"batch_started", "batch_completed", "batch_failed",
|
|
"oncall_assigned", "incident_created", "incident_resolved",
|
|
}
|
|
assert ev == expected, f"EVENT_TYPES mismatch: {ev}"
|
|
print(f" OK EVENT_TYPES = {len(ev)}개 정의")
|
|
|
|
# 라우터 존재 확인
|
|
assert hasattr(timeline_mod, "router"), "router 없음"
|
|
print(f" OK router 객체 존재")
|
|
|
|
# 헬퍼 함수 존재 확인
|
|
for fn in ["_collect_sr_events", "_collect_audit_events",
|
|
"_collect_deploy_events", "_collect_batch_events"]:
|
|
assert hasattr(timeline_mod, fn), f"{fn} 없음"
|
|
print(f" OK 이벤트 수집 헬퍼 4개 존재")
|
|
|
|
# 엔드포인트 경로 확인
|
|
routes = {r.path for r in timeline_mod.router.routes}
|
|
assert "/api/timeline" in routes, f"GET /api/timeline 없음: {routes}"
|
|
assert "/api/timeline/summary" in routes, f"GET /api/timeline/summary 없음: {routes}"
|
|
print(f" OK 엔드포인트: {sorted(routes)}")
|
|
|
|
except Exception as e:
|
|
print(f" ERR 임포트 오류: {type(e).__name__}: {e}")
|
|
ok = False
|
|
|
|
print("\n=== 3. 이벤트 수집 헬퍼 단위 테스트 ===")
|
|
|
|
async def test_collect_helpers():
|
|
"""DB 없이 필터 타입 로직만 테스트."""
|
|
from routers.timeline import _collect_sr_events, _collect_audit_events, \
|
|
_collect_deploy_events, _collect_batch_events, EVENT_TYPES
|
|
|
|
start = datetime.now() - timedelta(days=7)
|
|
end = datetime.now()
|
|
|
|
# 빈 filter_types 시 빈 결과 반환 확인
|
|
class MockDB:
|
|
async def execute(self, *a, **kw):
|
|
raise Exception("DB should not be called")
|
|
|
|
# sr_created 미포함 필터 → DB 호출 없이 빈 결과
|
|
result = await _collect_sr_events(MockDB(), start, end, {"deploy_started"})
|
|
assert result == [], f"Expected [] when no sr types in filter, got {result}"
|
|
print(f" OK sr 이벤트: 필터 미포함 시 DB 미조회")
|
|
|
|
# sr_status_changed 미포함 필터 → 빈 결과
|
|
result = await _collect_audit_events(MockDB(), start, end, {"sr_created"})
|
|
assert result == [], f"Expected [] for audit without sr_status_changed"
|
|
print(f" OK audit 이벤트: 필터 미포함 시 DB 미조회")
|
|
|
|
# deploy 타입 미포함 필터 → 빈 결과
|
|
result = await _collect_deploy_events(MockDB(), start, end, {"sr_created"})
|
|
assert result == [], f"Expected [] for deploy without deploy types"
|
|
print(f" OK deploy 이벤트: 필터 미포함 시 DB 미조회")
|
|
|
|
# batch 타입 미포함 필터 → 빈 결과
|
|
result = await _collect_batch_events(MockDB(), start, end, {"sr_created"})
|
|
assert result == [], f"Expected [] for batch without batch types"
|
|
print(f" OK batch 이벤트: 필터 미포함 시 DB 미조회")
|
|
|
|
asyncio.run(test_collect_helpers())
|
|
|
|
print("\n=== 4. 이벤트 정렬 및 페이지네이션 로직 테스트 ===")
|
|
|
|
def test_pagination_logic():
|
|
"""실제 이벤트 목록을 시뮬레이션하여 정렬/페이지네이션 검증."""
|
|
events = []
|
|
base = datetime(2024, 1, 10, 12, 0, 0)
|
|
for i in range(15):
|
|
ts = base - timedelta(hours=i)
|
|
events.append({
|
|
"id": f"sr_created_{i}",
|
|
"type": "sr_created",
|
|
"timestamp": ts.isoformat(),
|
|
"title": f"SR {i}",
|
|
"priority": "HIGH" if i % 2 == 0 else "LOW",
|
|
})
|
|
|
|
# 시간 역순 정렬
|
|
events.sort(key=lambda e: e["timestamp"], reverse=True)
|
|
assert events[0]["id"] == "sr_created_0", "최신 이벤트가 첫 번째여야 함"
|
|
assert events[-1]["id"] == "sr_created_14", "가장 오래된 이벤트가 마지막이어야 함"
|
|
print(" OK 시간 역순 정렬")
|
|
|
|
# 우선순위 필터
|
|
high_only = [e for e in events if e.get("priority") == "HIGH"]
|
|
assert len(high_only) == 8, f"HIGH 우선순위 8개 예상, 실제: {len(high_only)}"
|
|
print(f" OK 우선순위 필터 (HIGH: {len(high_only)}개)")
|
|
|
|
# 페이지네이션
|
|
total = len(events) # 15
|
|
skip, limit = 0, 5
|
|
page1 = events[skip:skip + limit]
|
|
assert len(page1) == 5
|
|
assert (skip + limit) < total # has_more = True
|
|
print(f" OK 페이지1 (skip=0, limit=5): {len(page1)}개, has_more=True")
|
|
|
|
skip2, limit2 = 10, 5
|
|
page2 = events[skip2:skip2 + limit2]
|
|
assert len(page2) == 5
|
|
assert not ((skip2 + limit2) < total) # has_more = False (10+5=15 = total)
|
|
print(f" OK 페이지3 (skip=10, limit=5): {len(page2)}개, has_more=False")
|
|
|
|
test_pagination_logic()
|
|
|
|
print("\n=== 5. 이벤트 구조 검증 ===")
|
|
|
|
def test_event_structure():
|
|
"""이벤트 딕셔너리 필수 키 검증."""
|
|
required_keys = {"id", "type", "timestamp", "title", "detail", "priority",
|
|
"ref_id", "actor", "icon", "color"}
|
|
|
|
# SR created 이벤트 시뮬레이션
|
|
sr_event = {
|
|
"id": "sr_created_SR-001",
|
|
"type": "sr_created",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"title": "SR 접수: 시스템 오류",
|
|
"detail": "우선순위: HIGH | 담당: 미배정",
|
|
"priority": "HIGH",
|
|
"ref_id": "SR-001",
|
|
"actor": "user1",
|
|
"icon": "ticket",
|
|
"color": "#2563eb",
|
|
}
|
|
missing = required_keys - sr_event.keys()
|
|
assert not missing, f"SR 이벤트 누락 키: {missing}"
|
|
print(" OK SR 이벤트 구조 (필수 키 10개)")
|
|
|
|
# SLA 위반 이벤트
|
|
sla_event = {
|
|
"id": "sr_sla_violated_SR-001",
|
|
"type": "sr_sla_violated",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"title": "SLA 위반: 시스템 오류",
|
|
"detail": "담당: 미배정 | 에스컬레이션: 없음",
|
|
"priority": "HIGH",
|
|
"ref_id": "SR-001",
|
|
"actor": "SYSTEM",
|
|
"icon": "alert",
|
|
"color": "#dc2626",
|
|
}
|
|
missing = required_keys - sla_event.keys()
|
|
assert not missing, f"SLA 이벤트 누락 키: {missing}"
|
|
print(" OK SLA 위반 이벤트 구조")
|
|
|
|
# 배포 이벤트
|
|
deploy_event = {
|
|
"id": "deploy_start_1",
|
|
"type": "deploy_started",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"title": "배포 시작: 세션 #1",
|
|
"detail": "SR: SR-001 | 시작: admin",
|
|
"priority": None,
|
|
"ref_id": "1",
|
|
"actor": "admin",
|
|
"icon": "rocket",
|
|
"color": "#7c3aed",
|
|
}
|
|
missing = required_keys - deploy_event.keys()
|
|
assert not missing, f"배포 이벤트 누락 키: {missing}"
|
|
print(" OK 배포 이벤트 구조")
|
|
|
|
test_event_structure()
|
|
|
|
print("\n=== 6. 요약 집계 로직 테스트 ===")
|
|
|
|
def test_summary_logic():
|
|
"""일별 카운트 집계 로직 검증."""
|
|
days = 7
|
|
end = datetime.now()
|
|
start = end - timedelta(days=days)
|
|
|
|
# by_day 초기화
|
|
by_day = {}
|
|
for d_offset in range(days):
|
|
d = (end - timedelta(days=d_offset)).date()
|
|
by_day[d.isoformat()] = {"sr": 0, "deploy": 0, "sla_violation": 0}
|
|
|
|
assert len(by_day) == 7, f"7일치 슬롯 필요, 실제: {len(by_day)}"
|
|
print(f" OK 7일치 집계 슬롯 초기화")
|
|
|
|
# 테스트 SR 데이터 집계
|
|
today_key = datetime.now().date().isoformat()
|
|
by_day[today_key]["sr"] += 3
|
|
by_day[today_key]["sla_violation"] += 1
|
|
|
|
yesterday_key = (datetime.now().date() - timedelta(days=1)).isoformat()
|
|
by_day[yesterday_key]["sr"] += 2
|
|
by_day[yesterday_key]["deploy"] += 5
|
|
|
|
totals = {
|
|
"sr": sum(v["sr"] for v in by_day.values()),
|
|
"deploy": sum(v["deploy"] for v in by_day.values()),
|
|
"sla_violation": sum(v["sla_violation"] for v in by_day.values()),
|
|
}
|
|
assert totals["sr"] == 5, f"SR 합계 5 예상, 실제: {totals['sr']}"
|
|
assert totals["deploy"] == 5, f"Deploy 합계 5 예상, 실제: {totals['deploy']}"
|
|
assert totals["sla_violation"] == 1, f"SLA 위반 합계 1 예상"
|
|
print(f" OK 일별 카운트 집계: SR={totals['sr']}, Deploy={totals['deploy']}, SLA={totals['sla_violation']}")
|
|
|
|
# sorted_days (오름차순)
|
|
sorted_days = sorted(by_day.items())
|
|
dates_list = [d for d, _ in sorted_days]
|
|
assert dates_list == sorted(dates_list), "날짜 오름차순 정렬 필요"
|
|
print(f" OK 날짜 오름차순 정렬")
|
|
|
|
test_summary_logic()
|
|
|
|
print("\n=== 7. main.py 등록 확인 ===")
|
|
with open("main.py", encoding="utf-8") as f:
|
|
main_src = f.read()
|
|
|
|
checks = [
|
|
("timeline", "timeline 라우터 임포트"),
|
|
("timeline.router", "timeline 라우터 등록"),
|
|
]
|
|
for sym, desc in checks:
|
|
status = "OK" if sym in main_src else "ERR"
|
|
if status == "ERR":
|
|
ok = False
|
|
print(f" {status} {desc} ({sym})")
|
|
|
|
print("\n=== A-4 타임라인 테스트 완료 ===")
|
|
if ok:
|
|
print("모든 검사 통과")
|
|
else:
|
|
sys.exit(1)
|