""" Terminal API Router Tests ========================= Phase 19.6: Router 層端點測試 (ADR-031) 測試策略: - Pydantic 驗證測試 (422): 不需外部依賴 - 404 錯誤測試: 不需外部依賴 - POST /intent 成功路徑: 使用共享 TerminalService 實例 - GET /status + POST /abort: 需共享 TerminalService 實例 DI 覆寫設計: 使用共享 TerminalService 避免跨請求 session 遺失。 實作採無狀態設計 (session 儲存在 self._sessions); 生產環境依賴 Redis session 持久化 (ADR-031)。 遵循: - feedback_no_mock_testing.md: 使用真實 TerminalService,非 MagicMock - ADR-031: Omni-Terminal SSE Architecture - ADR-024: Router 層只做 HTTP 轉發 Phase 19.6 ogt 2026-03-31 (台北時間) """ import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from src.api.v1.terminal import router from src.models.terminal import TerminalSessionStatus from src.services.terminal_service import TerminalService, get_terminal_service # ============================================================================= # Test App 設定 # ============================================================================= # 共享 TerminalService 實例 (避免跨請求 session 遺失) # 注意: 生產環境使用 Redis session 持久化;測試使用共享記憶體實例 _shared_service: TerminalService | None = None async def _get_test_service() -> TerminalService: """注入共享 TerminalService 實例""" global _shared_service if _shared_service is None: _shared_service = TerminalService() return _shared_service _test_app = FastAPI() _test_app.include_router(router, prefix="/api/v1") _test_app.dependency_overrides[get_terminal_service] = _get_test_service @pytest.fixture(autouse=True) def reset_service(): """每個測試前重置共享 Service 狀態,避免 Session 汙染""" global _shared_service _shared_service = TerminalService() yield _shared_service = None @pytest.fixture async def client(): """HTTP 測試客戶端 (ASGI Transport)""" async with AsyncClient( transport=ASGITransport(app=_test_app), base_url="http://test" ) as ac: yield ac @pytest.fixture def intent_payload(): """標準 Intent 請求 payload""" return { "intent": "check system status", "context": {"current_page": "/dashboard"}, } # ============================================================================= # POST /terminal/intent - 提交意圖 # ============================================================================= @pytest.mark.asyncio async def test_submit_intent_success(client, intent_payload): """成功提交意圖應返回 session_id + stream_url""" resp = await client.post("/api/v1/terminal/intent", json=intent_payload) assert resp.status_code == 200 data = resp.json() assert "session_id" in data assert "stream_url" in data assert "created_at" in data @pytest.mark.asyncio async def test_submit_intent_stream_url_pattern(client, intent_payload): """stream_url 必須包含 session_id,格式為 /api/v1/terminal/stream/{session_id}""" resp = await client.post("/api/v1/terminal/intent", json=intent_payload) assert resp.status_code == 200 data = resp.json() session_id = data["session_id"] assert session_id in data["stream_url"] assert data["stream_url"] == f"/api/v1/terminal/stream/{session_id}" @pytest.mark.asyncio async def test_submit_intent_missing_intent_field(client): """缺少必填 intent 欄位應返回 422""" resp = await client.post( "/api/v1/terminal/intent", json={"context": {"current_page": "/"}}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_submit_intent_missing_context_field(client): """缺少必填 context 欄位應返回 422""" resp = await client.post( "/api/v1/terminal/intent", json={"intent": "check status"}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_submit_intent_empty_string(client): """空 intent 字串應返回 422 (min_length=1)""" resp = await client.post( "/api/v1/terminal/intent", json={"intent": "", "context": {"current_page": "/"}}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_submit_intent_with_focused_entity(client): """帶 focused_entity_id 的 SpatialContext 應成功""" resp = await client.post( "/api/v1/terminal/intent", json={ "intent": "analyze this incident", "context": { "current_page": "/incidents", "focused_entity_id": "INC-2026-0001", }, }, ) assert resp.status_code == 200 assert "session_id" in resp.json() @pytest.mark.asyncio async def test_submit_intent_with_session_id(client): """帶 session_id 的續傳請求應成功,且返回相同 session_id""" resp = await client.post( "/api/v1/terminal/intent", json={ "intent": "continue analysis", "context": {"current_page": "/"}, "session_id": "test-session-001", }, ) assert resp.status_code == 200 assert resp.json()["session_id"] == "test-session-001" @pytest.mark.asyncio async def test_submit_intent_intent_too_long(client): """超過 max_length=2000 的 intent 應返回 422""" resp = await client.post( "/api/v1/terminal/intent", json={ "intent": "a" * 2001, "context": {"current_page": "/"}, }, ) assert resp.status_code == 422 # ============================================================================= # GET /terminal/status/{session_id} - 查詢狀態 # ============================================================================= @pytest.mark.asyncio async def test_get_status_after_submit(client, intent_payload): """提交意圖後應能查詢 Session 狀態""" # 先提交意圖 submit = await client.post("/api/v1/terminal/intent", json=intent_payload) assert submit.status_code == 200 session_id = submit.json()["session_id"] # 查詢狀態 status = await client.get(f"/api/v1/terminal/status/{session_id}") assert status.status_code == 200 data = status.json() assert data["session_id"] == session_id assert data["status"] in [s.value for s in TerminalSessionStatus] assert "created_at" in data assert isinstance(data["last_event_id"], int) assert isinstance(data["message_count"], int) @pytest.mark.asyncio async def test_get_status_not_found(client): """不存在的 session_id 應返回 404""" resp = await client.get("/api/v1/terminal/status/nonexistent-session-id") assert resp.status_code == 404 @pytest.mark.asyncio async def test_get_status_response_schema(client, intent_payload): """Status 回應必須包含所有必要欄位""" submit = await client.post("/api/v1/terminal/intent", json=intent_payload) session_id = submit.json()["session_id"] status = await client.get(f"/api/v1/terminal/status/{session_id}") data = status.json() required_fields = {"session_id", "status", "created_at", "last_event_id", "message_count"} assert required_fields.issubset(data.keys()) @pytest.mark.asyncio async def test_get_status_initial_state(client, intent_payload): """新建立的 Session 狀態應為 PROCESSING (背景任務已啟動)""" submit = await client.post("/api/v1/terminal/intent", json=intent_payload) session_id = submit.json()["session_id"] status = await client.get(f"/api/v1/terminal/status/{session_id}") data = status.json() # 剛建立的 session 應為 PROCESSING (背景任務已啟動) # 或 COMPLETED 若背景任務已快速完成 assert data["status"] in ["processing", "completed", "error"] # ============================================================================= # POST /terminal/abort/{session_id} - 中斷執行 # ============================================================================= @pytest.mark.asyncio async def test_abort_not_found(client): """中斷不存在的 session_id 應返回 404""" resp = await client.post("/api/v1/terminal/abort/nonexistent-session") assert resp.status_code == 404 @pytest.mark.asyncio async def test_abort_with_reason_not_found(client): """帶理由中斷不存在的 session 應返回 404""" resp = await client.post( "/api/v1/terminal/abort/nonexistent-session", json={"reason": "user cancelled"}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_abort_existing_session(client, intent_payload): """中斷已存在的 session 應返回 200 (aborted=True)""" # 建立 session submit = await client.post("/api/v1/terminal/intent", json=intent_payload) assert submit.status_code == 200 session_id = submit.json()["session_id"] # 中斷 abort = await client.post(f"/api/v1/terminal/abort/{session_id}") assert abort.status_code == 200 data = abort.json() assert data["session_id"] == session_id assert data["aborted"] is True assert "message" in data @pytest.mark.asyncio async def test_abort_with_reason(client, intent_payload): """帶理由的中斷應成功,message 包含理由""" submit = await client.post("/api/v1/terminal/intent", json=intent_payload) session_id = submit.json()["session_id"] abort = await client.post( f"/api/v1/terminal/abort/{session_id}", json={"reason": "user pressed Escape"}, ) assert abort.status_code == 200 data = abort.json() assert data["aborted"] is True assert "user pressed Escape" in data["message"] @pytest.mark.asyncio async def test_abort_updates_session_status(client, intent_payload): """中斷後 Session 狀態應更新為 ABORTED""" submit = await client.post("/api/v1/terminal/intent", json=intent_payload) session_id = submit.json()["session_id"] # 中斷 await client.post(f"/api/v1/terminal/abort/{session_id}") # 驗證狀態 status = await client.get(f"/api/v1/terminal/status/{session_id}") assert status.status_code == 200 assert status.json()["status"] == TerminalSessionStatus.ABORTED.value # ============================================================================= # GET /terminal/stream/{session_id} - SSE 串流 # ============================================================================= @pytest.mark.asyncio async def test_stream_session_not_found(client): """不存在的 session_id 的串流應返回 404 (在連接 Redis 之前)""" resp = await client.get("/api/v1/terminal/stream/nonexistent-stream-session") assert resp.status_code == 404