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>
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""
|
||
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
|