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>
143 lines
5.6 KiB
Python
143 lines
5.6 KiB
Python
"""A-5 On-Call 자동 로테이션 테스트"""
|
|
import sys, ast, os, json
|
|
|
|
os.environ.setdefault("GUARDIA_SECRET_KEY", "test-secret-key-32bytes-padding!!")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_a5.db")
|
|
|
|
# ── 1. 구문 검사 ─────────────────────────────────────────────────────────────
|
|
print("=== 1. 구문 검사 ===")
|
|
files = [
|
|
"core/oncall_rotate.py",
|
|
"routers/oncall.py",
|
|
"models.py",
|
|
"core/scheduler.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)
|
|
|
|
# ── 2. OncallRotateConfig 모델 스키마 확인 ───────────────────────────────────
|
|
print("\n=== 2. 모델 스키마 확인 ===")
|
|
from models import OncallRotateConfig, OncallRotateConfigOut, OncallRotateConfigUpdate
|
|
|
|
cfg_fields = [
|
|
"id", "is_active", "engineer_list", "current_index",
|
|
"rotate_days", "default_shift", "escalation_chain",
|
|
"notify_on_assign", "advance_days",
|
|
]
|
|
table_cols = {c.key for c in OncallRotateConfig.__table__.columns}
|
|
for field in cfg_fields:
|
|
status = "OK" if field in table_cols else "ERR"
|
|
print(f" {status} OncallRotateConfig.{field}")
|
|
|
|
out_fields = OncallRotateConfigOut.model_fields
|
|
for field in ["id", "is_active", "engineer_list", "current_index", "rotate_days"]:
|
|
status = "OK" if field in out_fields else "ERR"
|
|
print(f" {status} OncallRotateConfigOut.{field}")
|
|
|
|
# ── 3. core/oncall_rotate.py 임포트 ─────────────────────────────────────────
|
|
print("\n=== 3. oncall_rotate 임포트 테스트 ===")
|
|
try:
|
|
from core.oncall_rotate import (
|
|
get_or_create_rotate_config,
|
|
get_current_oncall,
|
|
auto_rotate_oncall,
|
|
escalate_oncall,
|
|
_notify_oncall_assigned,
|
|
)
|
|
print(" OK 모든 함수 임포트 성공")
|
|
for fn_name, fn in [
|
|
("get_or_create_rotate_config", get_or_create_rotate_config),
|
|
("get_current_oncall", get_current_oncall),
|
|
("auto_rotate_oncall", auto_rotate_oncall),
|
|
("escalate_oncall", escalate_oncall),
|
|
]:
|
|
import asyncio as _asyncio
|
|
import inspect
|
|
if inspect.iscoroutinefunction(fn):
|
|
print(f" OK {fn_name} is async")
|
|
else:
|
|
print(f" ERR {fn_name} is NOT async")
|
|
except ImportError as e:
|
|
print(f" ERR 임포트 실패: {e}")
|
|
ok = False
|
|
|
|
# ── 4. JSON 직렬화 로직 검증 ─────────────────────────────────────────────────
|
|
print("\n=== 4. JSON 직렬화 로직 검증 ===")
|
|
# engineer_list JSON 직렬화/역직렬화
|
|
engineers = ["alice", "bob", "charlie"]
|
|
serialized = json.dumps(engineers, ensure_ascii=False)
|
|
deserialized = json.loads(serialized)
|
|
assert deserialized == engineers, "JSON roundtrip failed"
|
|
print(f" OK engineer_list JSON: {serialized}")
|
|
|
|
# 로테이션 인덱스 순환
|
|
for idx in range(6):
|
|
next_idx = (idx + 1) % len(engineers)
|
|
engineer = engineers[idx % len(engineers)]
|
|
# 마지막 idx=5 → idx%3=2 → charlie, next_idx=0
|
|
assert engineer == "charlie", f"Expected charlie, got {engineer}"
|
|
assert next_idx == 0, f"Expected 0, got {next_idx}"
|
|
print(f" OK 로테이션 순환 인덱스 (0→1→2→0)")
|
|
|
|
# advance_days 날짜 계산
|
|
from datetime import date, timedelta
|
|
advance = 1
|
|
target = date.today() + timedelta(days=advance)
|
|
assert target > date.today(), "target_date should be after today"
|
|
print(f" OK advance_days=1 → target_date={target}")
|
|
|
|
# ── 5. 라우터 엔드포인트 확인 ────────────────────────────────────────────────
|
|
print("\n=== 5. 라우터 엔드포인트 확인 ===")
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location("oncall_mod", "routers/oncall.py")
|
|
oncall_mod = importlib.util.module_from_spec(spec)
|
|
try:
|
|
spec.loader.exec_module(oncall_mod)
|
|
router = oncall_mod.router
|
|
routes = {}
|
|
for r in router.routes:
|
|
if hasattr(r, "methods"):
|
|
routes[r.path] = list(r.methods)
|
|
|
|
expected = [
|
|
"/rotate/config",
|
|
"/on-duty",
|
|
"/escalate",
|
|
"/rotate/trigger",
|
|
]
|
|
for path in expected:
|
|
found = any(path in r for r in routes.keys())
|
|
status = "OK" if found else "WARN"
|
|
print(f" {status} 경로 존재: {path}")
|
|
print(f" INFO 전체 라우트: {list(routes.keys())}")
|
|
except Exception as e:
|
|
print(f" INFO 라우터 로드 중 외부 의존성 에러 (정상): {type(e).__name__}: {str(e)[:80]}")
|
|
|
|
# ── 6. 스케줄러 job 등록 확인 ────────────────────────────────────────────────
|
|
print("\n=== 6. scheduler.py oncall 작업 확인 ===")
|
|
with open("core/scheduler.py", encoding="utf-8") as f:
|
|
sched_src = f.read()
|
|
|
|
if "oncall_auto_rotate" in sched_src:
|
|
print(" OK oncall_auto_rotate job id 존재")
|
|
if "auto_rotate_oncall" in sched_src:
|
|
print(" OK auto_rotate_oncall 함수 참조 존재")
|
|
if "On-Call 자동 로테이션 (00:05)" in sched_src:
|
|
print(" OK job name 존재")
|
|
|
|
print("\n=== 테스트 완료: A-5 On-Call 자동 로테이션 ===")
|
|
if ok:
|
|
print("모든 검사 통과")
|
|
else:
|
|
sys.exit(1)
|