""" Phase 19.6: Terminal Service Tests ================================== Omni-Terminal SSE 架構測試 Phase 22 P1 修復: 移除 Mock,使用真實服務 2026-03-31 Claude Code (首席架構師) 測試內容: 1. 意圖分類 (classify_intent) - 純函數 2. Model 驗證 - 純 Pydantic 3. Service 依賴注入 4. 整合測試 (需要 Redis/K8s) 遵循規範: - feedback_no_mock_testing.md: 禁止 MagicMock/AsyncMock/patch - ADR-031 Omni-Terminal SSE Architecture """ import pytest from src.models.terminal import ( SpatialContext, TerminalIntentRequest, TerminalSessionStatus, ) from src.services.terminal_service import ( IntentType, TerminalService, classify_intent, get_terminal_service, ) # ============================================================================= # Test Markers # ============================================================================= requires_database = pytest.mark.skipif( "not config.getoption('--run-integration', default=False)", reason="Need --run-integration option to run database integration tests", ) requires_k8s = pytest.mark.skipif( "not config.getoption('--run-k8s', default=False)", reason="Need --run-k8s option to run K8s integration tests", ) # ============================================================================= # Intent Classification Tests - Pure Functions # ============================================================================= 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 - Pure Pydantic # ============================================================================= 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}" # ============================================================================= # Integration Tests - Require Real Services # ============================================================================= # Phase 22 P1 修復: 移除 Mock,使用真實服務 # 2026-03-31 Claude Code (首席架構師) # ============================================================================= class TestHandleApprovalActionIntegration: """ _handle_approval_action 整合測試 需要: - PostgreSQL (ApprovalService) - Redis (SSE Publisher) """ @pytest.mark.asyncio @requires_database async def test_approval_action_returns_pending_list(self): """ 測試查詢待簽核項目 使用真實 ApprovalService 查詢資料庫 """ from src.services.approval_service import get_approval_service service = TerminalService() approval_service = get_approval_service() # 查詢真實的待簽核項目 pending = await approval_service.get_pending_approvals() # 驗證返回格式 assert isinstance(pending, list) @pytest.mark.asyncio @requires_database async def test_approval_service_connection(self): """測試 ApprovalService 連線""" from src.services.approval_service import get_approval_service service = get_approval_service() # 應該能成功取得服務實例 assert service is not None class TestHandleStatusQueryIntegration: """ _handle_status_query 整合測試 需要: - K8s 連線 (K8sRepository) """ @pytest.mark.asyncio @requires_k8s async def test_k8s_repository_available(self): """測試 K8sRepository 連線""" from src.repositories.k8s_repository import get_k8s_repository repo = get_k8s_repository() is_available = await repo.is_available() # 如果 K8s 可用,應該返回 True # 如果在 Mock 模式,也應該返回 True assert isinstance(is_available, bool) @pytest.mark.asyncio @requires_k8s async def test_k8s_pod_status_summary(self): """測試 K8s Pod 狀態摘要""" from src.repositories.k8s_repository import get_k8s_repository repo = get_k8s_repository() if not await repo.is_available(): pytest.skip("K8s not available") summary = await repo.get_pod_status_summary() # 驗證返回格式 assert "total" in summary assert "running" in summary assert "pending" in summary assert "failed" in summary @pytest.mark.asyncio @requires_k8s async def test_k8s_list_deployments(self): """測試 K8s Deployment 列表""" from src.repositories.k8s_repository import get_k8s_repository repo = get_k8s_repository() if not await repo.is_available(): pytest.skip("K8s not available") deployments = await repo.list_deployments() # 驗證返回格式 assert isinstance(deployments, list) class TestTerminalServiceIntegration: """ TerminalService 完整整合測試 需要: - Redis (SSE Publisher) - PostgreSQL (ApprovalService) """ @pytest.mark.asyncio @requires_database async def test_terminal_service_with_real_publisher(self): """ 測試 TerminalService 搭配真實 SSE Publisher 驗證: 1. 能建立 Session 2. 能發送 SSE 事件 """ from src.core.sse import get_sse_publisher service = TerminalService() publisher = get_sse_publisher() # 驗證 publisher 實例 assert publisher is not None @pytest.mark.asyncio @requires_database async def test_process_intent_with_real_services(self): """ 測試意圖處理 (不含 SSE 輸出) 驗證: 1. 意圖分類正確 2. Session 狀態正確 """ service = TerminalService() # 測試意圖分類 intent_type = classify_intent("check status") assert intent_type == IntentType.QUERY_STATUS intent_type = classify_intent("show pending approvals") assert intent_type == IntentType.ACTION_APPROVAL