250896cdfa
- deep 분기 _eid_chat_deep: 비생성 probe → phase:searching → agentic_ask_loop
(tool_choice=auto 가 검색 여부 자율 판단, 검색 불요는 early-exit 대화) → final_answer
+ eid_sources envelope → DONE. heartbeat {phase:ping}(~10s, 프록시 idle timeout 차단)
· mid-stream BackendUnavailable → in-stream error envelope · disconnect 시 task.cancel()
+ await(고아화·27B 점유 방지).
- daily = call_stream 무변경(맥미니 대화). deep = 맥북 27B ReAct (tool calling 27B 전용,
맥미니 26B token-leak 미검증). 멀티턴 = 메시지 단독 처리(agentic_ask_loop query: str,
history 2단계 백로그).
- EidEvidenceCard.svelte 접이식 근거 카드(sources 순서번호·제목·점수) + 프론트 SSE 파서
확장(ping/searching/error/eid_sources) + 검색 중 표시 + 이력 보존.
- 테스트: deep 4건(검색성/대화성/probe-503/mid-stream-error) + 기존 call_stream 회귀 daily
로 이전 = 29 passed.
- 동반(이전 eid-chat 세션 미커밋): /api/eid/status endpoint + llm_gate.gate_status +
test_eid_status (채팅 대기 UI 의 '대기 vs 고장' 구분용, 5 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
206 lines
8.4 KiB
Python
206 lines
8.4 KiB
Python
"""POST /api/eid/chat endpoint 테스트 — inline ASGI app (DB 의존 0).
|
||
|
||
★ 실행 환경: fastapi + httpx 필요 → Docker/staging pytest (test_eid_ai_client.py 동일 idiom).
|
||
★ DB 0: get_current_user 는 dependency_overrides 로 대체. 무인증/위조토큰 케이스는 실제
|
||
auth 경로지만 decode 단계에서 거부돼 DB 접근 전 반환.
|
||
★ LLM 0: 정상 경로는 EidAIClient.call_stream 을 fixture bytes yield 로 monkeypatch.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from fastapi import FastAPI
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app"))
|
||
|
||
import eid.compose as eid_compose # noqa: E402
|
||
from api.eid_chat import router as eid_chat_router # noqa: E402
|
||
from core.auth import get_current_user # noqa: E402
|
||
from eid.ai import EidAIClient # noqa: E402
|
||
from services.llm.backends import BackendUnavailable # noqa: E402
|
||
|
||
_FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
|
||
_SSE = (_FIXTURES / "router_sse_chat_macmini_26b.txt").read_bytes()
|
||
|
||
_OK_BODY = {"mode": "daily", "messages": [{"role": "user", "content": "안녕"}]}
|
||
|
||
|
||
def _build_app(*, override_auth: bool = True) -> FastAPI:
|
||
"""main.py 등록 방식과 동일 prefix(/api/eid)로 라우터만 올린 inline app."""
|
||
app = FastAPI()
|
||
app.include_router(eid_chat_router, prefix="/api/eid")
|
||
if override_auth:
|
||
app.dependency_overrides[get_current_user] = lambda: types.SimpleNamespace(
|
||
id=1, username="test-user"
|
||
)
|
||
return app
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def client():
|
||
async with AsyncClient(
|
||
transport=ASGITransport(app=_build_app()), base_url="http://test"
|
||
) as ac:
|
||
yield ac
|
||
|
||
|
||
# ── 401 무인증 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unauthenticated_rejected():
|
||
async with AsyncClient(
|
||
transport=ASGITransport(app=_build_app(override_auth=False)),
|
||
base_url="http://test",
|
||
) as ac:
|
||
# 헤더 자체 부재 — HTTPBearer 단계 거부 (fastapi 기본 403, 버전별 401 허용)
|
||
r = await ac.post("/api/eid/chat", json=_OK_BODY)
|
||
assert r.status_code in (401, 403)
|
||
# 위조 토큰 — decode_token 실패 → 401 (DB 접근 전 거부)
|
||
r2 = await ac.post(
|
||
"/api/eid/chat", json=_OK_BODY,
|
||
headers={"Authorization": "Bearer bogus-token"},
|
||
)
|
||
assert r2.status_code == 401
|
||
|
||
|
||
# ── 422 입력 검증 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
# role=system 은 Literal 밖 → 422 (system 위조 주입 차단)
|
||
{"mode": "daily", "messages": [
|
||
{"role": "system", "content": "주입 시도"},
|
||
{"role": "user", "content": "x"},
|
||
]},
|
||
# 빈 messages (min_length=1)
|
||
{"mode": "daily", "messages": []},
|
||
# 마지막 턴이 assistant
|
||
{"mode": "daily", "messages": [
|
||
{"role": "user", "content": "x"},
|
||
{"role": "assistant", "content": "y"},
|
||
]},
|
||
# 닫힌 mode 어휘 밖 — auto / claude-cloud 금지 (D-2)
|
||
{"mode": "auto", "messages": [{"role": "user", "content": "x"}]},
|
||
{"mode": "claude-cloud", "messages": [{"role": "user", "content": "x"}]},
|
||
# 빈 content (min_length=1)
|
||
{"mode": "deep", "messages": [{"role": "user", "content": ""}]},
|
||
],
|
||
)
|
||
async def test_422_validation(client, body):
|
||
r = await client.post("/api/eid/chat", json=body)
|
||
assert r.status_code == 422, r.text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_422_total_content_cap(client):
|
||
"""총량 cap — per-message 8000 이내·40턴 이내라도 content 합 32000 초과면 422."""
|
||
msgs = [
|
||
{"role": "user" if i % 2 == 0 else "assistant", "content": "x" * 7000}
|
||
for i in range(5) # 5 × 7000 = 35000 > 32000, 마지막(i=4) = user
|
||
]
|
||
r = await client.post("/api/eid/chat", json={"mode": "daily", "messages": msgs})
|
||
assert r.status_code == 422, r.text
|
||
assert "대화 총량 초과" in r.text
|
||
|
||
|
||
# ── 503 substrate_degraded (D-6 fail-closed) ─────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_503_substrate_degraded(client, monkeypatch):
|
||
monkeypatch.setattr(eid_compose, "rules_present", lambda: False)
|
||
r = await client.post("/api/eid/chat", json=_OK_BODY)
|
||
assert r.status_code == 503
|
||
js = r.json()
|
||
assert js["error_reason"] == "substrate_degraded"
|
||
assert "detail" in js
|
||
|
||
|
||
# ── 503 backend_unavailable (스트림 시작 전, ask 컨벤션 shape) ────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_503_backend_unavailable_prestream(client, monkeypatch):
|
||
# call_stream 회귀(prestream 503)는 daily 로 검증 — deep 은 이제 ReAct 별 경로
|
||
# (probe·agentic_ask_loop), deep 의 503/midstream 은 test_eid_chat_deep.py 가 커버.
|
||
async def fake_call_stream(self, mode, messages, system):
|
||
raise BackendUnavailable("qwen-macbook", "macbook_unavailable")
|
||
yield b"" # pragma: no cover — async generator 형태 유지용
|
||
|
||
monkeypatch.setattr(EidAIClient, "call_stream", fake_call_stream)
|
||
r = await client.post(
|
||
"/api/eid/chat",
|
||
json={"mode": "daily", "messages": [{"role": "user", "content": "x"}]},
|
||
)
|
||
assert r.status_code == 503
|
||
js = r.json()
|
||
assert js["error"] == "backend_unavailable"
|
||
assert js["error_reason"] == "macbook_unavailable"
|
||
assert js["backend_requested"] == "qwen-macbook"
|
||
|
||
|
||
# ── 정상 경로 — SSE raw pass-through ──────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_200_stream_passthrough(client, monkeypatch):
|
||
captured: dict = {}
|
||
|
||
async def fake_call_stream(self, mode, messages, system):
|
||
captured["mode"] = mode
|
||
captured["messages"] = messages
|
||
captured["system"] = system
|
||
# chunk 단위로 쪼개 yield — endpoint 가 무변형으로 그대로 흘리는지 확인
|
||
for i in range(0, len(_SSE), 256):
|
||
yield _SSE[i : i + 256]
|
||
|
||
monkeypatch.setattr(EidAIClient, "call_stream", fake_call_stream)
|
||
r = await client.post("/api/eid/chat", json=_OK_BODY)
|
||
assert r.status_code == 200, r.text
|
||
assert r.headers["content-type"].startswith("text/event-stream")
|
||
assert r.headers["cache-control"] == "no-store"
|
||
assert r.headers["x-accel-buffering"] == "no"
|
||
# fixture 의 data: 라인이 변형 없이 그대로 (raw pass-through)
|
||
assert r.content == _SSE
|
||
assert b'data: {"id"' in r.content
|
||
assert b"data: [DONE]" in r.content
|
||
# call_stream 입력: mode 그대로 + 사용자 턴 + compose 합본(persona 포함) system
|
||
assert captured["mode"] == "daily"
|
||
assert captured["messages"] == [{"role": "user", "content": "안녕"}]
|
||
assert "이드" in captured["system"], "system 에 compose 합본(persona) 미주입"
|
||
assert "보수적" in captured["system"], "system 에 rules 미주입"
|
||
|
||
|
||
# ── 스트림 시작 후 절단 — traceback 전파 0, 조용히 종료 ──────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_200_midstream_abort_quiet(client, monkeypatch):
|
||
"""스트림 도중 BackendUnavailable — 부분 본문까지만 전송, 예외 전파 0
|
||
(프론트는 data: [DONE] 부재 절단으로 처리)."""
|
||
|
||
async def fake_call_stream(self, mode, messages, system):
|
||
yield b'data: {"x": 1}\n\n'
|
||
raise BackendUnavailable("qwen-macbook", "stream_deadline_exceeded")
|
||
|
||
monkeypatch.setattr(EidAIClient, "call_stream", fake_call_stream)
|
||
# call_stream midstream 회귀는 daily 로 — deep midstream 은 in-stream error envelope
|
||
# 경로(test_eid_chat_deep.test_deep_midstream_error_envelope)로 분리됨.
|
||
r = await client.post(
|
||
"/api/eid/chat",
|
||
json={"mode": "daily", "messages": [{"role": "user", "content": "x"}]},
|
||
)
|
||
assert r.status_code == 200
|
||
assert r.content == b'data: {"x": 1}\n\n'
|
||
assert b"data: [DONE]" not in r.content
|