Files
hyungi_document_server/tests/eid/test_eid_chat_endpoint.py
T
hyungi 250896cdfa feat(eid): deep 모드 = ReAct 자동검색 + 근거 카드 (ds-eid-ask-absorb P1)
- 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>
2026-06-11 14:51:00 +09:00

206 lines
8.4 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.
"""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