Files
awoooi/apps/api/tests/test_terminal_service.py
OG T 13bb1496b0
Some checks failed
E2E Health Check / e2e-health (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
refactor(api): Phase B P1 可靠性強化 (2 項)
1. send_cicd_progress 重試機制 (指數退避 1,2,4 秒)
2. K8s Repository 封裝 (IK8sRepository + K8sRepository)

首席架構師審查 P1 改進 - 模組化合規

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-30 01:52:59 +08:00

450 lines
15 KiB
Python

"""
Phase 19.6: Terminal Service Tests
==================================
Omni-Terminal SSE 架構測試
測試內容:
1. 意圖分類 (classify_intent)
2. Service 依賴注入
3. Model 驗證
@see ADR-031 Omni-Terminal SSE Architecture
@author Claude Code (首席架構師)
@version 1.0.0
@date 2026-03-28 (台北時間)
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from src.models.approval import ApprovalRequest, ApprovalStatus, RiskLevel
from src.models.terminal import (
SpatialContext,
TerminalIntentRequest,
TerminalSessionStatus,
)
from src.services.terminal_service import (
IntentType,
TerminalService,
classify_intent,
get_terminal_service,
)
# =============================================================================
# Intent Classification Tests
# =============================================================================
INTENT_CLASSIFICATION_TEST_CASES = [
# 查詢類 - 狀態
("status", IntentType.QUERY_STATUS),
("狀態", IntentType.QUERY_STATUS),
("pod status", IntentType.QUERY_STATUS),
("健康狀態", IntentType.QUERY_STATUS),
("health check", IntentType.QUERY_STATUS),
# 查詢類 - 指標
("metrics", IntentType.QUERY_METRICS),
("指標", IntentType.QUERY_METRICS),
("cpu usage", IntentType.QUERY_METRICS),
("記憶體使用量", IntentType.QUERY_METRICS),
("memory utilization", IntentType.QUERY_METRICS),
# 查詢類 - 日誌
("logs", IntentType.QUERY_LOGS),
("日誌", IntentType.QUERY_LOGS),
("error logs", IntentType.QUERY_LOGS),
("錯誤訊息", IntentType.QUERY_LOGS),
("exception trace", IntentType.QUERY_LOGS),
# 操作類 - 簽核
("approve", IntentType.ACTION_APPROVAL),
("簽核", IntentType.ACTION_APPROVAL),
("授權", IntentType.ACTION_APPROVAL),
("批准這個操作", IntentType.ACTION_APPROVAL),
("show pending approval", IntentType.ACTION_APPROVAL),
# 操作類 - 重啟
("restart", IntentType.ACTION_RESTART),
("重啟", IntentType.ACTION_RESTART),
("rollout", IntentType.ACTION_RESTART),
("重新部署", IntentType.ACTION_RESTART),
# 操作類 - 擴容
("scale", IntentType.ACTION_SCALE),
("擴容", IntentType.ACTION_SCALE),
("增加副本", IntentType.ACTION_SCALE),
("replica count", IntentType.ACTION_SCALE),
# 分析類 - RCA
("rca", IntentType.ANALYZE_RCA),
("root cause", IntentType.ANALYZE_RCA),
("根因", IntentType.ANALYZE_RCA),
("分析問題", IntentType.ANALYZE_RCA),
("為什麼 API 變慢", IntentType.ANALYZE_RCA),
("why is it slow", IntentType.ANALYZE_RCA),
# 分析類 - Incident
("incident", IntentType.ANALYZE_INCIDENT),
("事件", IntentType.ANALYZE_INCIDENT),
("alert 告警", IntentType.ANALYZE_INCIDENT),
("告警詳情", IntentType.ANALYZE_INCIDENT),
# 一般
("hello", IntentType.GENERAL),
("你好", IntentType.GENERAL),
("help", IntentType.GENERAL),
("random text", IntentType.GENERAL),
]
@pytest.mark.parametrize("intent,expected_type", INTENT_CLASSIFICATION_TEST_CASES)
def test_classify_intent(intent: str, expected_type: IntentType):
"""測試意圖分類準確度"""
result = classify_intent(intent)
assert result == expected_type, f"Intent '{intent}' should be classified as {expected_type}, got {result}"
def test_classify_intent_case_insensitive():
"""測試意圖分類不區分大小寫"""
assert classify_intent("STATUS") == IntentType.QUERY_STATUS
assert classify_intent("Metrics") == IntentType.QUERY_METRICS
assert classify_intent("RESTART") == IntentType.ACTION_RESTART
# =============================================================================
# Model Validation Tests
# =============================================================================
def test_spatial_context_required_fields():
"""測試 SpatialContext 必填欄位"""
context = SpatialContext(current_page="/dashboard")
assert context.current_page == "/dashboard"
assert context.focused_entity_id is None # 預設為 None
def test_spatial_context_with_entity():
"""測試 SpatialContext 帶實體 ID"""
context = SpatialContext(
current_page="/incidents",
focused_entity_id="INC-001",
)
assert context.current_page == "/incidents"
assert context.focused_entity_id == "INC-001"
def test_terminal_intent_request_validation():
"""測試 TerminalIntentRequest 驗證"""
request = TerminalIntentRequest(
intent="check system status",
context=SpatialContext(current_page="/dashboard"),
)
assert request.intent == "check system status"
assert request.context.current_page == "/dashboard"
assert request.session_id is None # 預設為 None
def test_terminal_intent_request_with_session():
"""測試帶 session_id 的 TerminalIntentRequest"""
request = TerminalIntentRequest(
intent="continue analysis",
context=SpatialContext(current_page="/"),
session_id="sess-001",
)
assert request.session_id == "sess-001"
def test_terminal_session_status_enum():
"""測試 TerminalSessionStatus 枚舉值"""
assert TerminalSessionStatus.PENDING.value == "pending"
assert TerminalSessionStatus.PROCESSING.value == "processing"
assert TerminalSessionStatus.COMPLETED.value == "completed"
assert TerminalSessionStatus.ERROR.value == "error"
assert TerminalSessionStatus.ABORTED.value == "aborted"
# =============================================================================
# Dependency Injection Tests
# =============================================================================
@pytest.mark.asyncio
async def test_get_terminal_service():
"""測試 FastAPI 依賴注入函數"""
service = await get_terminal_service()
assert isinstance(service, TerminalService)
@pytest.mark.asyncio
async def test_get_terminal_service_creates_new_instance():
"""測試每次呼叫建立新實例 (非 Singleton)"""
service1 = await get_terminal_service()
service2 = await get_terminal_service()
assert service1 is not service2, "Should create new instance each time"
# =============================================================================
# Service Unit Tests (No External Dependencies)
# =============================================================================
@pytest.mark.asyncio
async def test_terminal_service_instantiation():
"""測試 TerminalService 實例化"""
service = TerminalService()
assert service._sessions == {}
assert service._tasks == {}
@pytest.mark.asyncio
async def test_get_session_not_found():
"""測試取得不存在的 Session"""
service = TerminalService()
session = await service.get_session("nonexistent-session")
assert session is None
@pytest.mark.asyncio
async def test_abort_session_not_found():
"""測試中斷不存在的 Session"""
service = TerminalService()
result = await service.abort_session("nonexistent-session")
assert result is False
# =============================================================================
# Intent Coverage Statistics
# =============================================================================
def test_intent_type_coverage():
"""確保所有 IntentType 都有測試案例"""
tested_types = {expected for _, expected in INTENT_CLASSIFICATION_TEST_CASES}
all_types = set(IntentType)
missing = all_types - tested_types
assert not missing, f"Missing test cases for IntentType: {missing}"
# =============================================================================
# Phase 19.4: API Integration Tests
# =============================================================================
# @author Claude Code
# @date 2026-03-30 (台北時間)
# =============================================================================
@pytest.fixture
def mock_publisher():
"""模擬 SSE Publisher"""
publisher = AsyncMock()
publisher.publish = AsyncMock()
return publisher
@pytest.fixture
def sample_approval():
"""測試用 Approval"""
return ApprovalRequest(
id=uuid4(),
action="kubectl rollout restart deployment/awoooi-api -n awoooi",
description="重啟 API 服務以套用新設定",
status=ApprovalStatus.PENDING,
risk_level=RiskLevel.MEDIUM,
required_signatures=1,
current_signatures=0,
signatures=[],
requested_by="system",
)
class TestHandleApprovalAction:
"""_handle_approval_action Phase 19.4 測試"""
@pytest.mark.asyncio
async def test_approval_action_with_pending(self, mock_publisher, sample_approval):
"""測試有待簽核項目時的處理"""
service = TerminalService()
mock_approval_service = MagicMock()
mock_approval_service.get_pending_approvals = AsyncMock(
return_value=[sample_approval]
)
with patch(
"src.services.terminal_service.get_approval_service",
return_value=mock_approval_service,
):
await service._handle_approval_action(
mock_publisher,
"test_topic",
"test_session",
"approval",
)
# 驗證 publish 被呼叫多次 (thought + tool_call + render_ui)
assert mock_publisher.publish.call_count >= 3
@pytest.mark.asyncio
async def test_approval_action_no_pending(self, mock_publisher):
"""測試沒有待簽核項目時的處理"""
service = TerminalService()
mock_approval_service = MagicMock()
mock_approval_service.get_pending_approvals = AsyncMock(return_value=[])
with patch(
"src.services.terminal_service.get_approval_service",
return_value=mock_approval_service,
):
await service._handle_approval_action(
mock_publisher,
"test_topic",
"test_session",
"approval",
)
# 驗證有發送 "沒有待簽核項目" 的訊息
calls = mock_publisher.publish.call_args_list
found_no_pending = False
for call in calls:
event = call[0][0]
if hasattr(event, 'data') and isinstance(event.data, dict):
msg = event.data.get('msg', '')
if '沒有待簽核' in msg:
found_no_pending = True
break
assert found_no_pending, "Should mention no pending approvals"
@pytest.mark.asyncio
async def test_approval_action_error_handling(self, mock_publisher):
"""測試查詢失敗時的錯誤處理"""
service = TerminalService()
mock_approval_service = MagicMock()
mock_approval_service.get_pending_approvals = AsyncMock(
side_effect=Exception("Database error")
)
with patch(
"src.services.terminal_service.get_approval_service",
return_value=mock_approval_service,
):
# 不應拋出例外
await service._handle_approval_action(
mock_publisher,
"test_topic",
"test_session",
"approval",
)
# 驗證有發送錯誤訊息
assert mock_publisher.publish.call_count >= 2
class TestHandleStatusQuery:
"""_handle_status_query Phase 19.4 測試 (P1: 使用 K8sRepository)"""
@pytest.mark.asyncio
async def test_status_query_success(self, mock_publisher):
"""測試 K8s 狀態查詢成功 (P1: 使用 K8sRepository)"""
service = TerminalService()
# Mock K8sRepository
mock_k8s_repo = MagicMock()
mock_k8s_repo.is_available = AsyncMock(return_value=True)
mock_k8s_repo.get_pod_status_summary = AsyncMock(return_value={
"total": 1,
"running": 1,
"pending": 0,
"failed": 0,
"problem_pods": [],
})
mock_k8s_repo.list_deployments = AsyncMock(return_value=[
{"name": "awoooi-api", "ready_replicas": 1, "replicas": 1, "available": 1}
])
with patch(
"src.services.terminal_service.get_k8s_repository",
return_value=mock_k8s_repo,
):
await service._handle_status_query(
mock_publisher,
"test_topic",
"test_session",
"status",
)
# 驗證成功查詢
assert mock_publisher.publish.call_count >= 2
@pytest.mark.asyncio
async def test_status_query_with_problem_pods(self, mock_publisher):
"""測試有問題 Pods 時的狀態查詢 (P1: 使用 K8sRepository)"""
service = TerminalService()
# Mock K8sRepository
mock_k8s_repo = MagicMock()
mock_k8s_repo.is_available = AsyncMock(return_value=True)
mock_k8s_repo.get_pod_status_summary = AsyncMock(return_value={
"total": 2,
"running": 1,
"pending": 1,
"failed": 0,
"problem_pods": [
{"name": "awoooi-worker-bad", "phase": "Pending", "ready": False, "restarts": 0}
],
})
mock_k8s_repo.list_deployments = AsyncMock(return_value=[])
with patch(
"src.services.terminal_service.get_k8s_repository",
return_value=mock_k8s_repo,
):
await service._handle_status_query(
mock_publisher,
"test_topic",
"test_session",
"status",
)
# 驗證有提到問題 Pods
calls = mock_publisher.publish.call_args_list
found_problem_pod = False
for call in calls:
event = call[0][0]
if hasattr(event, 'data') and isinstance(event.data, dict):
msg = event.data.get('msg', '')
if '問題 Pods' in msg or 'Pending' in msg:
found_problem_pod = True
break
assert found_problem_pod, "Should mention problem pods"
@pytest.mark.asyncio
async def test_status_query_k8s_unavailable(self, mock_publisher):
"""測試 K8s 連線失敗時的處理 (P1: 使用 K8sRepository)"""
service = TerminalService()
# Mock K8sRepository - 不可用
mock_k8s_repo = MagicMock()
mock_k8s_repo.is_available = AsyncMock(return_value=False)
with patch(
"src.services.terminal_service.get_k8s_repository",
return_value=mock_k8s_repo,
):
# 不應拋出例外
await service._handle_status_query(
mock_publisher,
"test_topic",
"test_session",
"status",
)
# 驗證有發送錯誤訊息
assert mock_publisher.publish.call_count >= 2