Files
awoooi/apps/api/tests/test_terminal.py
OG T 22de22c989 refactor(phase-s): Phase S 技術債清理 - 五項架構改善
S-01: generate_alert_fingerprint() 移至 alert_analyzer_service (Router→Service)
S-02: 移除廢棄 USE_NEW_ENGINE config (Phase R 已完成歷史使命)
S-03: github_webhook.py linter 清理 (Field unused + delivery_id noqa)
S-04: Pydantic v2 遷移 - approval/incident models (class Config → ConfigDict)
S-05: Skill 09 v1.1 更新 (USE_NEW_ENGINE 廢棄說明)

測試: 393 passed, 零失敗

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:12:02 +08:00

322 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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