Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
## Critical 修復 (C1-C5) - C1: git rm --cached 03-secrets.yaml(CHANGE_ME 模板不再追蹤) - C2: git rm --cached awoooi.db + .gitignore 加 *.db(SQLite HARD_RULES 違規) - C3: sentry-tunnel SENTRY_HOST 改為 process.env fallback - C4: config.py DATABASE_URL 移除 changeme default,改為必填 - C5: run_migration.py 改為 os.environ["DATABASE_URL"] ## Major 修復 (M1-M4) - M1: auto_repair /execute 加 CSRF 保護 + AutoRepairPanel.tsx 同步 - M2: drift /rollback /adopt 加 CSRF 保護(/internal/scan 保持無 CSRF) - M3: terminal /intent 加 CSRF 保護 + terminal.store.ts 同步 - M4: live-dashboard HOST_IPS + host-grid VIP 改為 env var ## 其他 - 新增 apps/web/.env.example(6 個 env var 說明) - K8s deployment-web 補入 3 個新 env var - 整合測試:新增 aider_event_repository + ai_router_feedback 真實 DB 測試 - test_terminal.py CSRF dependency override 修復 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
324 lines
11 KiB
Python
324 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.core.csrf import verify_csrf_token
|
||
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
|
||
_test_app.dependency_overrides[verify_csrf_token] = lambda: "test-bypass" # tests have no browser session
|
||
|
||
|
||
@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
|