Initial commit: import from sinmb79/Gov-chat-bot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
# 모든 모델을 명시적으로 임포트하여 Base.metadata에 등록 (FK 순서 보장)
|
||||
from app.models import tenant as _m1 # noqa: F401
|
||||
from app.models import admin as _m2 # noqa: F401
|
||||
from app.models import knowledge as _m3 # noqa: F401
|
||||
from app.models import complaint as _m4 # noqa: F401
|
||||
from app.models import moderation as _m5 # noqa: F401
|
||||
from app.models import audit as _m6 # noqa: F401
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def db_engine():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def db_session(db_engine):
|
||||
async_session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(db_engine):
|
||||
async_session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def override_get_db():
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
# app 참조를 client에 노출 (테스트에서 app.state 패치 용도)
|
||||
ac.app = app
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
관리자 인증 API 테스트.
|
||||
POST /api/admin/auth/login
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models.admin import AdminUser, AdminRole
|
||||
from app.core.security import hash_password
|
||||
|
||||
|
||||
def make_user(tenant_id: str = "t1", email: str = "admin@test.com") -> AdminUser:
|
||||
user = AdminUser()
|
||||
user.id = str(uuid4())
|
||||
user.tenant_id = tenant_id
|
||||
user.email = email
|
||||
user.hashed_pw = hash_password("secret123")
|
||||
user.role = AdminRole.admin
|
||||
user.is_active = True
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client):
|
||||
"""올바른 자격증명 → access_token + role 반환."""
|
||||
user = make_user()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none = MagicMock(return_value=user)
|
||||
|
||||
from app.core.database import get_db
|
||||
|
||||
async def override_db():
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(return_value=mock_result)
|
||||
yield db
|
||||
|
||||
client.app.dependency_overrides[get_db] = override_db
|
||||
|
||||
try:
|
||||
res = await client.post("/api/admin/auth/login", json={
|
||||
"tenant_id": user.tenant_id,
|
||||
"email": user.email,
|
||||
"password": "secret123",
|
||||
})
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["role"] == "admin"
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client):
|
||||
"""틀린 비밀번호 → 401."""
|
||||
user = make_user()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none = MagicMock(return_value=user)
|
||||
|
||||
from app.core.database import get_db
|
||||
|
||||
async def override_db():
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(return_value=mock_result)
|
||||
yield db
|
||||
|
||||
client.app.dependency_overrides[get_db] = override_db
|
||||
|
||||
try:
|
||||
res = await client.post("/api/admin/auth/login", json={
|
||||
"tenant_id": user.tenant_id,
|
||||
"email": user.email,
|
||||
"password": "wrong-password",
|
||||
})
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_user_not_found(client):
|
||||
"""존재하지 않는 사용자 → 401."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none = MagicMock(return_value=None)
|
||||
|
||||
from app.core.database import get_db
|
||||
|
||||
async def override_db():
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(return_value=mock_result)
|
||||
yield db
|
||||
|
||||
client.app.dependency_overrides[get_db] = override_db
|
||||
|
||||
try:
|
||||
res = await client.post("/api/admin/auth/login", json={
|
||||
"tenant_id": "nonexistent",
|
||||
"email": "nobody@test.com",
|
||||
"password": "any",
|
||||
})
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_fields(client):
|
||||
"""필수 필드 누락 → 422."""
|
||||
res = await client.post("/api/admin/auth/login", json={"email": "x@x.com"})
|
||||
assert res.status_code == 422
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
메트릭 조회 API 테스트.
|
||||
GET /api/admin/metrics
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models.admin import AdminUser, AdminRole
|
||||
from app.core.security import create_admin_token, hash_password
|
||||
|
||||
|
||||
def make_user(tenant_id: str = "t1") -> AdminUser:
|
||||
user = AdminUser()
|
||||
user.id = str(uuid4())
|
||||
user.tenant_id = tenant_id
|
||||
user.email = "admin@test.com"
|
||||
user.hashed_pw = hash_password("pw")
|
||||
user.role = AdminRole.admin
|
||||
user.is_active = True
|
||||
return user
|
||||
|
||||
|
||||
def auth_headers(user: AdminUser) -> dict:
|
||||
token = create_admin_token(user.id, user.tenant_id, user.role.value)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_no_auth_returns_401(client):
|
||||
"""인증 없이 접근 → 401."""
|
||||
res = await client.get("/api/admin/metrics")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_db_fallback(client):
|
||||
"""Redis 없을 때 DB 집계로 메트릭 반환."""
|
||||
user = make_user()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
mock = MagicMock()
|
||||
if call_count == 1:
|
||||
# get_current_admin: AdminUser 조회
|
||||
mock.scalar_one_or_none = MagicMock(return_value=user)
|
||||
return mock
|
||||
sql = str(query)
|
||||
if "response_tier" in sql or "group_by" in sql.lower():
|
||||
mock.all = MagicMock(return_value=[("A", 5), ("B", 3), ("D", 2)])
|
||||
elif "is_timeout" in sql:
|
||||
mock.scalar = MagicMock(return_value=1)
|
||||
else:
|
||||
mock.scalar = MagicMock(return_value=10)
|
||||
return mock
|
||||
|
||||
from app.core.database import get_db
|
||||
|
||||
async def override_db():
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(side_effect=fake_execute)
|
||||
yield db
|
||||
|
||||
client.app.dependency_overrides[get_db] = override_db
|
||||
client.app.state.redis = None
|
||||
|
||||
try:
|
||||
res = await client.get("/api/admin/metrics", headers=auth_headers(user))
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "total_count" in data
|
||||
assert "tier_counts" in data
|
||||
assert "timeout_count" in data
|
||||
assert set(data["tier_counts"].keys()) == {"A", "B", "C", "D"}
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(get_db, None)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
감사 로그 단위 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, call
|
||||
|
||||
from app.services.audit import log_action
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_action_creates_audit_entry():
|
||||
"""log_action 호출 시 AuditLog 추가 및 커밋."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
entry = await log_action(
|
||||
db=db,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
actor_type="admin_user",
|
||||
action="doc.upload",
|
||||
target_type="document",
|
||||
target_id="doc-1",
|
||||
diff={"filename": "guide.txt"},
|
||||
)
|
||||
|
||||
db.add.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
assert entry.action == "doc.upload"
|
||||
assert entry.tenant_id == "tenant-1"
|
||||
assert entry.actor_id == "user-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_action_doc_approve():
|
||||
"""doc.approve 액션 기록."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
entry = await log_action(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
actor_id="editor-1",
|
||||
actor_type="admin_user",
|
||||
action="doc.approve",
|
||||
target_type="document",
|
||||
target_id="doc-2",
|
||||
)
|
||||
|
||||
assert entry.action == "doc.approve"
|
||||
assert entry.target_id == "doc-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_action_includes_diff():
|
||||
"""diff 필드가 저장됨."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
diff = {"before": "pending", "after": "processed"}
|
||||
entry = await log_action(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
actor_id="user-1",
|
||||
actor_type="admin_user",
|
||||
action="config.update",
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
assert entry.diff == diff
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_action_without_target():
|
||||
"""target_type/target_id 없어도 정상 기록."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
entry = await log_action(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
actor_id="user-1",
|
||||
actor_type="system_admin",
|
||||
action="config.update",
|
||||
)
|
||||
|
||||
assert entry.target_type is None
|
||||
assert entry.target_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_crawler_approve():
|
||||
"""crawler.approve 감사 로그."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
entry = await log_action(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
actor_id="editor-1",
|
||||
actor_type="admin_user",
|
||||
action="crawler.approve",
|
||||
target_type="crawler_url",
|
||||
target_id="url-1",
|
||||
diff={"url": "https://www.dongducheon.go.kr"},
|
||||
)
|
||||
|
||||
assert entry.action == "crawler.approve"
|
||||
assert entry.diff["url"] == "https://www.dongducheon.go.kr"
|
||||
@@ -0,0 +1,65 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.core.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_admin_token,
|
||||
create_system_token,
|
||||
decode_token,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def test_hash_and_verify_password():
|
||||
"""해싱 후 verify True, 다른 비밀번호는 False."""
|
||||
hashed = hash_password("secret123")
|
||||
assert verify_password("secret123", hashed) is True
|
||||
assert verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
def test_create_admin_token_has_tenant_id():
|
||||
"""decode 시 payload['tenant_id'] == 'tenant-id', type == 'admin_user'."""
|
||||
token = create_admin_token("user-1", "tenant-id", "admin")
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["tenant_id"] == "tenant-id"
|
||||
assert payload["type"] == "admin_user"
|
||||
|
||||
|
||||
def test_create_system_token_has_no_tenant_id():
|
||||
"""decode 시 payload['tenant_id'] is None, type == 'system_admin'."""
|
||||
token = create_system_token("sys-admin-1")
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["tenant_id"] is None
|
||||
assert payload["type"] == "system_admin"
|
||||
|
||||
|
||||
def test_decode_invalid_token_returns_none():
|
||||
"""decode_token('invalid.token') == None."""
|
||||
assert decode_token("invalid.token") is None
|
||||
|
||||
|
||||
def test_decode_expired_token_returns_none():
|
||||
"""만료 토큰 생성 후 decode == None."""
|
||||
payload = {
|
||||
"sub": "user-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(seconds=1),
|
||||
}
|
||||
expired_token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
assert decode_token(expired_token) is None
|
||||
|
||||
|
||||
def test_admin_and_system_token_different_type():
|
||||
"""두 토큰의 type 필드 값 다름."""
|
||||
admin_token = create_admin_token("user-1", "tenant-1", "admin")
|
||||
system_token = create_system_token("sys-1")
|
||||
|
||||
admin_payload = decode_token(admin_token)
|
||||
system_payload = decode_token(system_token)
|
||||
|
||||
assert admin_payload["type"] != system_payload["type"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
민원 이력 저장 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.services.complaint_logger import log_complaint
|
||||
from app.services.routing import RoutingResult
|
||||
|
||||
|
||||
def make_result(tier="D", source="fallback", request_id="req-1") -> RoutingResult:
|
||||
return RoutingResult(
|
||||
answer="답변",
|
||||
tier=tier,
|
||||
source=source,
|
||||
elapsed_ms=100,
|
||||
is_timeout=False,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_complaint_masks_utterance():
|
||||
"""민원 이력에 원문 미저장, 마스킹 후 저장."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
raw_utterance = "제 전화번호는 010-1234-5678 입니다"
|
||||
result = make_result()
|
||||
|
||||
entry = await log_complaint(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
raw_utterance=raw_utterance,
|
||||
raw_user_id="kakao-user-123",
|
||||
result=result,
|
||||
)
|
||||
|
||||
# 원문 미저장
|
||||
assert "010-1234-5678" not in (entry.utterance_masked or "")
|
||||
# 마스킹 처리됨
|
||||
assert "***-****-****" in (entry.utterance_masked or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_complaint_hashes_user_id():
|
||||
"""user_key는 해시값(16자리)으로 저장."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
result = make_result()
|
||||
entry = await log_complaint(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
raw_utterance="일반 민원",
|
||||
raw_user_id="real-kakao-id-12345",
|
||||
result=result,
|
||||
)
|
||||
|
||||
assert entry.user_key != "real-kakao-id-12345"
|
||||
assert len(entry.user_key) == 16
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_complaint_stores_tier_and_source():
|
||||
"""response_tier, response_source 저장."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
result = make_result(tier="A", source="faq")
|
||||
entry = await log_complaint(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
raw_utterance="질문",
|
||||
raw_user_id="user-1",
|
||||
result=result,
|
||||
)
|
||||
|
||||
assert entry.response_tier == "A"
|
||||
assert entry.response_source == "faq"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_complaint_stores_request_id():
|
||||
"""request_id 저장 (Idempotency 추적)."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
result = make_result(request_id="unique-req-abc")
|
||||
entry = await log_complaint(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
raw_utterance="질문",
|
||||
raw_user_id="user-1",
|
||||
result=result,
|
||||
)
|
||||
|
||||
assert entry.request_id == "unique-req-abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_complaint_timeout_flag():
|
||||
"""타임아웃 시 is_timeout=True 저장."""
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
result = RoutingResult(
|
||||
answer="타임아웃",
|
||||
tier="D",
|
||||
source="fallback",
|
||||
elapsed_ms=4500,
|
||||
is_timeout=True,
|
||||
)
|
||||
|
||||
entry = await log_complaint(
|
||||
db=db,
|
||||
tenant_id="t1",
|
||||
raw_utterance="질문",
|
||||
raw_user_id="user-1",
|
||||
result=result,
|
||||
)
|
||||
|
||||
assert entry.is_timeout is True
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
크롤러 서비스 단위 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.services.crawler import crawl_url, check_robots_txt, CrawlerService
|
||||
from app.services.parsers.text_parser import extract_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_extracts_text():
|
||||
"""정상 HTML 응답 → 텍스트 추출."""
|
||||
html_str = "<html><body><p>동두천시 여권 안내</p><script>bad()</script></body></html>"
|
||||
html = html_str.encode("utf-8")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html
|
||||
mock_response.text = html_str
|
||||
mock_response.headers = {"content-type": "text/html"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("app.services.crawler.check_robots_txt", return_value=True), \
|
||||
patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = await crawl_url("https://www.example.com/guide")
|
||||
|
||||
assert result is not None
|
||||
assert "동두천시" in result
|
||||
assert "bad()" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_returns_none_on_robots_disallow():
|
||||
"""robots.txt 불허 → None 반환."""
|
||||
with patch("app.services.crawler.check_robots_txt", return_value=False):
|
||||
result = await crawl_url("https://www.example.com/private")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_url_returns_none_on_network_error():
|
||||
"""네트워크 오류 → None 반환 (예외 미전파)."""
|
||||
import httpx
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
||||
|
||||
with patch("app.services.crawler.check_robots_txt", return_value=True), \
|
||||
patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = await crawl_url("https://unreachable.example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawler_service_updates_last_crawled():
|
||||
"""run() 후 last_crawled 업데이트."""
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
crawler_url = MagicMock()
|
||||
crawler_url.url = "https://www.example.com"
|
||||
crawler_url.last_crawled = None
|
||||
|
||||
with patch("app.services.crawler.crawl_url", return_value="크롤링된 내용"):
|
||||
service = CrawlerService(db)
|
||||
result = await service.run(crawler_url, "tenant-1")
|
||||
|
||||
assert result == "크롤링된 내용"
|
||||
assert crawler_url.last_crawled is not None
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawler_service_returns_none_on_fail():
|
||||
"""크롤링 실패 → None 반환."""
|
||||
db = AsyncMock()
|
||||
crawler_url = MagicMock()
|
||||
crawler_url.url = "https://fail.example.com"
|
||||
|
||||
with patch("app.services.crawler.crawl_url", return_value=None):
|
||||
service = CrawlerService(db)
|
||||
result = await service.run(crawler_url, "tenant-1")
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
DocumentProcessor + 텍스트 파서 단위 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.services.parsers.text_parser import extract_text, chunk_text
|
||||
from app.services.document_processor import DocumentProcessor
|
||||
|
||||
|
||||
# ── 파서 테스트 ─────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_text_txt():
|
||||
content = "안녕하세요\n여권 발급 안내입니다".encode("utf-8")
|
||||
result = extract_text(content, "guide.txt")
|
||||
assert "여권" in result
|
||||
|
||||
|
||||
def test_extract_text_md():
|
||||
content = "# 제목\n본문 내용".encode("utf-8")
|
||||
result = extract_text(content, "readme.md")
|
||||
assert "본문" in result
|
||||
|
||||
|
||||
def test_extract_text_html():
|
||||
content = "<html><body><p>여권 안내</p><script>alert(1)</script></body></html>".encode("utf-8")
|
||||
result = extract_text(content, "page.html")
|
||||
assert "여권" in result
|
||||
assert "alert" not in result
|
||||
|
||||
|
||||
def test_extract_text_unsupported_returns_none():
|
||||
result = extract_text(b"binary", "file.exe")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_chunk_text_splits_correctly():
|
||||
text = "\n".join(["문장 " + str(i) for i in range(50)])
|
||||
chunks = chunk_text(text, chunk_size=100)
|
||||
assert len(chunks) > 1
|
||||
for chunk in chunks:
|
||||
assert len(chunk) > 0
|
||||
|
||||
|
||||
def test_chunk_text_small_text_single_chunk():
|
||||
text = "짧은 텍스트"
|
||||
chunks = chunk_text(text, chunk_size=500)
|
||||
assert len(chunks) == 1
|
||||
assert "짧은 텍스트" in chunks[0]
|
||||
|
||||
|
||||
# ── DocumentProcessor 테스트 ────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processor_stores_chunks_in_vectordb():
|
||||
"""파싱 성공 → VectorDB에 upsert 호출."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768, [0.2] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.upsert = AsyncMock(return_value=2)
|
||||
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
doc = MagicMock()
|
||||
doc.id = "doc-1"
|
||||
doc.filename = "test.txt"
|
||||
doc.published_at = None
|
||||
doc.status = "pending"
|
||||
doc.chunk_count = 0
|
||||
|
||||
processor = DocumentProcessor(embedding, vectordb, db)
|
||||
content = "첫 번째 문단입니다.\n두 번째 문단입니다.".encode("utf-8")
|
||||
count = await processor.process("tenant-1", doc, content)
|
||||
|
||||
assert count > 0
|
||||
assert vectordb.upsert.called
|
||||
assert doc.status == "processed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processor_fails_on_unsupported_format():
|
||||
"""지원하지 않는 파일 형식 → chunk_count=0, status=parse_failed."""
|
||||
embedding = AsyncMock()
|
||||
vectordb = AsyncMock()
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
doc = MagicMock()
|
||||
doc.id = "doc-2"
|
||||
doc.filename = "file.exe"
|
||||
doc.published_at = None
|
||||
doc.status = "pending"
|
||||
|
||||
processor = DocumentProcessor(embedding, vectordb, db)
|
||||
count = await processor.process("tenant-1", doc, b"binary data")
|
||||
|
||||
assert count == 0
|
||||
assert doc.status == "parse_failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processor_handles_embedding_not_implemented():
|
||||
"""임베딩 미구성 → status=embedding_unavailable."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(side_effect=NotImplementedError)
|
||||
|
||||
vectordb = AsyncMock()
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
doc = MagicMock()
|
||||
doc.id = "doc-3"
|
||||
doc.filename = "guide.txt"
|
||||
doc.published_at = None
|
||||
doc.status = "pending"
|
||||
|
||||
processor = DocumentProcessor(embedding, vectordb, db)
|
||||
count = await processor.process("tenant-1", doc, "본문 내용".encode("utf-8"))
|
||||
|
||||
assert count == 0
|
||||
assert doc.status == "embedding_unavailable"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
POST /engine/query 엔드포인트 테스트 (웹 시뮬레이터).
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_query_returns_correct_structure(client):
|
||||
"""POST /engine/query → 필수 필드 포함 응답."""
|
||||
response = await client.post(
|
||||
"/engine/query",
|
||||
json={
|
||||
"tenant": "test-tenant",
|
||||
"utterance": "여권 발급 방법 알려주세요",
|
||||
"user_key": "test-user-key",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "answer" in data
|
||||
assert "tier" in data
|
||||
assert "source" in data
|
||||
assert "citations" in data
|
||||
assert "request_id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_query_tier_d_when_no_faq(client):
|
||||
"""FAQ 없으면 Tier D 폴백 반환."""
|
||||
response = await client.post(
|
||||
"/engine/query",
|
||||
json={
|
||||
"tenant": "test-tenant",
|
||||
"utterance": "알 수 없는 질문",
|
||||
"user_key": "user-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tier"] == "D"
|
||||
assert data["source"] == "fallback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_query_idempotency_deduplication(client):
|
||||
"""같은 request_id로 두 번 요청 → 동일 응답 (캐시 HIT)."""
|
||||
payload = {
|
||||
"tenant": "test-tenant",
|
||||
"utterance": "중복 요청 테스트",
|
||||
"user_key": "user-1",
|
||||
"request_id": "dup-req-001",
|
||||
}
|
||||
|
||||
# Mock Redis for idempotency
|
||||
mock_redis = AsyncMock()
|
||||
cached_result = {
|
||||
"answer": "캐시된 응답",
|
||||
"tier": "D",
|
||||
"source": "fallback",
|
||||
"citations": [],
|
||||
"request_id": "dup-req-001",
|
||||
"elapsed_ms": 10,
|
||||
"is_timeout": False,
|
||||
}
|
||||
import json
|
||||
mock_redis.get = AsyncMock(return_value=json.dumps(cached_result).encode())
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
# app.state에 직접 설정 (lifespan 미실행 상태)
|
||||
client.app.state.redis = mock_redis
|
||||
try:
|
||||
resp1 = await client.post("/engine/query", json=payload)
|
||||
finally:
|
||||
client.app.state.redis = None
|
||||
|
||||
assert resp1.status_code == 200
|
||||
assert resp1.json()["answer"] == "캐시된 응답"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_query_request_id_auto_generated(client):
|
||||
"""request_id 미전송 시 자동 생성."""
|
||||
response = await client.post(
|
||||
"/engine/query",
|
||||
json={
|
||||
"tenant": "test-tenant",
|
||||
"utterance": "테스트",
|
||||
"user_key": "user-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["request_id"] is not None
|
||||
assert len(data["request_id"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_query_channel_default_web(client):
|
||||
"""channel 미전송 시 기본값 web 사용 — 응답은 정상."""
|
||||
response = await client.post(
|
||||
"/engine/query",
|
||||
json={
|
||||
"tenant": "test-tenant",
|
||||
"utterance": "테스트",
|
||||
"user_key": "user-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
FAQSearchService 단위 테스트.
|
||||
임베딩/벡터DB는 Mock 사용.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.faq_search import FAQSearchService, FAQ_SIMILARITY_THRESHOLD
|
||||
from app.providers.base import SearchResult
|
||||
|
||||
|
||||
def make_faq(tenant_id: str, question: str = "Q", answer: str = "A") -> MagicMock:
|
||||
faq = MagicMock()
|
||||
faq.id = str(uuid4())
|
||||
faq.tenant_id = tenant_id
|
||||
faq.question = question
|
||||
faq.answer = answer
|
||||
faq.is_active = True
|
||||
faq.hit_count = 0
|
||||
faq.updated_at = None
|
||||
return faq
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faq_search_above_threshold_returns_faq():
|
||||
"""유사도 ≥ 0.85 → FAQ 반환."""
|
||||
faq = make_faq("t1")
|
||||
faq_id = faq.id
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.92, metadata={"faq_id": faq_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=faq)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
service = FAQSearchService(embedding, vectordb, db)
|
||||
result = await service.search("t1", "여권 발급")
|
||||
|
||||
assert result is not None
|
||||
found_faq, score = result
|
||||
assert found_faq.id == faq_id
|
||||
assert score >= FAQ_SIMILARITY_THRESHOLD
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faq_search_below_threshold_returns_none():
|
||||
"""유사도 < 0.85 → None 반환."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[]) # threshold 미달로 빈 결과
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
service = FAQSearchService(embedding, vectordb, db)
|
||||
result = await service.search("t1", "모르는 질문")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faq_search_tenant_isolation():
|
||||
"""다른 테넌트의 FAQ가 반환되지 않는다."""
|
||||
faq_tenant_b = make_faq("tenant-B")
|
||||
faq_id = faq_tenant_b.id
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.95, metadata={"faq_id": faq_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
# tenant-A로 조회하면 None (다른 테넌트 FAQ)
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=None)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
service = FAQSearchService(embedding, vectordb, db)
|
||||
result = await service.search("tenant-A", "테스트 질문") # tenant-A로 검색
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faq_search_hit_count_increment():
|
||||
"""FAQ 검색 성공 시 hit_count가 증가한다."""
|
||||
faq = make_faq("t1")
|
||||
faq.hit_count = 5
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="Q\nA", doc_id=f"{faq.id}_0", score=0.90,
|
||||
metadata={"faq_id": faq.id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=faq)
|
||||
db.execute.return_value = scalar
|
||||
db.get = AsyncMock(return_value=faq)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
service = FAQSearchService(embedding, vectordb, db)
|
||||
result = await service.search("t1", "질문")
|
||||
assert result is not None
|
||||
|
||||
await service.increment_hit(faq.id)
|
||||
assert faq.hit_count == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faq_search_embedding_not_implemented_returns_none():
|
||||
"""EmbeddingProvider가 NotImplementedError → None 반환 (graceful)."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(side_effect=NotImplementedError("not configured"))
|
||||
|
||||
vectordb = AsyncMock()
|
||||
db = AsyncMock()
|
||||
|
||||
service = FAQSearchService(embedding, vectordb, db)
|
||||
result = await service.search("t1", "질문")
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
from app.services.idempotency import IdempotencyCache
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
|
||||
async def get(self, key):
|
||||
return self._store.get(key)
|
||||
|
||||
async def setex(self, key, ttl, value):
|
||||
self._store[key] = value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_returns_none():
|
||||
"""get('tenant-1', 'req-1') == None (캐시 없음)."""
|
||||
cache = IdempotencyCache(FakeRedis())
|
||||
result = await cache.get("tenant-1", "req-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get_returns_same_data():
|
||||
"""set 후 get → 동일 데이터."""
|
||||
cache = IdempotencyCache(FakeRedis())
|
||||
data = {"answer": "test", "tier": "A"}
|
||||
await cache.set("tenant-1", "req-1", data)
|
||||
result = await cache.get("tenant-1", "req-1")
|
||||
assert result == data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_tenant_same_request_id_no_collision():
|
||||
"""tenant-A에 저장, tenant-B에서 같은 request_id get → None."""
|
||||
cache = IdempotencyCache(FakeRedis())
|
||||
await cache.set("tenant-A", "req-1", {"answer": "A"})
|
||||
result = await cache.get("tenant-B", "req-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_request_id_returns_none():
|
||||
"""get(tenant_id, None) == None."""
|
||||
cache = IdempotencyCache(FakeRedis())
|
||||
result = await cache.get("tenant-1", None)
|
||||
assert result is None
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
LLM Provider 인터페이스 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from app.providers.llm import NullLLMProvider
|
||||
from app.providers.llm_anthropic import AnthropicLLMProvider, OpenAILLMProvider
|
||||
from app.providers import get_llm_provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_provider_always_returns_none():
|
||||
provider = NullLLMProvider()
|
||||
result = await provider.generate("sys", "user", ["ctx"])
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_provider_returns_none_without_context():
|
||||
provider = NullLLMProvider()
|
||||
result = await provider.generate("sys", "user", [])
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_llm_provider_none():
|
||||
provider = get_llm_provider({"LLM_PROVIDER": "none"})
|
||||
assert isinstance(provider, NullLLMProvider)
|
||||
|
||||
|
||||
def test_get_llm_provider_anthropic():
|
||||
provider = get_llm_provider({"LLM_PROVIDER": "anthropic", "ANTHROPIC_API_KEY": "test"})
|
||||
assert isinstance(provider, AnthropicLLMProvider)
|
||||
|
||||
|
||||
def test_get_llm_provider_unknown_raises():
|
||||
with pytest.raises(ValueError):
|
||||
get_llm_provider({"LLM_PROVIDER": "unknown_xyz"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_provider_no_context_returns_none():
|
||||
"""근거 없으면 API 호출 없이 None."""
|
||||
provider = AnthropicLLMProvider(api_key="test-key")
|
||||
result = await provider.generate("sys", "user", context_chunks=[])
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_provider_api_failure_returns_none():
|
||||
"""API 오류 → None (예외 미전파)."""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
provider = AnthropicLLMProvider(api_key="invalid-key")
|
||||
|
||||
with patch("anthropic.AsyncAnthropic") as mock_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.messages.create = AsyncMock(side_effect=Exception("API error"))
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
result = await provider.generate("sys", "user", context_chunks=["근거"])
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
|
||||
from app.services.masking import mask_text, hash_user_key, has_sensitive_data
|
||||
|
||||
|
||||
def test_rrn_full_masking():
|
||||
"""주민번호 마스킹."""
|
||||
result = mask_text("주민번호: 901225-1234567")
|
||||
assert result == "주민번호: ######-*######"
|
||||
|
||||
|
||||
def test_phone_masking():
|
||||
"""전화번호 마스킹."""
|
||||
result = mask_text("전화: 010-1234-5678")
|
||||
assert result == "전화: ***-****-****"
|
||||
|
||||
|
||||
def test_email_masking():
|
||||
"""이메일 마스킹."""
|
||||
result = mask_text("user@example.com")
|
||||
assert result == "***@***.***"
|
||||
|
||||
|
||||
def test_card_masking():
|
||||
"""카드번호 마스킹."""
|
||||
result = mask_text("4242-4242-4242-4242")
|
||||
assert result == "****-****-****-****"
|
||||
|
||||
|
||||
def test_no_false_positive_number():
|
||||
"""금액 숫자는 마스킹되지 않아야 함."""
|
||||
original = "예산액: 1,234,567원"
|
||||
result = mask_text(original)
|
||||
assert result == original
|
||||
|
||||
|
||||
def test_multiple_patterns_in_one_text():
|
||||
"""전화번호와 이메일이 같이 있는 문장에서 둘 다 마스킹."""
|
||||
text = "연락처: 010-1234-5678, 이메일: admin@gov.kr"
|
||||
result = mask_text(text)
|
||||
assert "010-1234-5678" not in result
|
||||
assert "admin@gov.kr" not in result
|
||||
assert "***-****-****" in result
|
||||
assert "***@***.***" in result
|
||||
|
||||
|
||||
def test_hash_user_key_length():
|
||||
"""hash_user_key 결과 길이 == 16."""
|
||||
assert len(hash_user_key("any_id")) == 16
|
||||
|
||||
|
||||
def test_hash_user_key_deterministic():
|
||||
"""같은 입력에 항상 같은 결과."""
|
||||
assert hash_user_key("test_user") == hash_user_key("test_user")
|
||||
|
||||
|
||||
def test_has_sensitive_data_detection():
|
||||
"""민감 데이터 감지."""
|
||||
assert has_sensitive_data("010-1234-5678") is True
|
||||
assert has_sensitive_data("일반 민원 내용입니다") is False
|
||||
@@ -0,0 +1,114 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.services.metrics import MetricsCollector
|
||||
from app.services.routing import RoutingResult
|
||||
|
||||
|
||||
def make_result(source="faq", elapsed_ms=100, is_timeout=False):
|
||||
return RoutingResult(
|
||||
answer="test",
|
||||
tier="A",
|
||||
source=source,
|
||||
elapsed_ms=elapsed_ms,
|
||||
is_timeout=is_timeout,
|
||||
)
|
||||
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
self._sets = {}
|
||||
self._calls = []
|
||||
|
||||
def hincrby(self, key, field, amount):
|
||||
if key not in self._data:
|
||||
self._data[key] = {}
|
||||
self._data[key][field] = self._data[key].get(field, 0) + amount
|
||||
return self
|
||||
|
||||
def zadd(self, key, mapping):
|
||||
if key not in self._sets:
|
||||
self._sets[key] = {}
|
||||
self._sets[key].update(mapping)
|
||||
return self
|
||||
|
||||
def zremrangebyrank(self, key, start, stop):
|
||||
return self
|
||||
|
||||
async def execute(self):
|
||||
return []
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
self._sets = {}
|
||||
self._pipeline = FakePipeline()
|
||||
|
||||
def pipeline(self):
|
||||
return self._pipeline
|
||||
|
||||
async def hgetall(self, key):
|
||||
return {k: str(v) for k, v in self._pipeline._data.get(key, {}).items()}
|
||||
|
||||
async def zcard(self, key):
|
||||
return len(self._pipeline._sets.get(key, {}))
|
||||
|
||||
async def zrange(self, key, start, stop, withscores=False):
|
||||
items = sorted(self._pipeline._sets.get(key, {}).items(), key=lambda x: x[1])
|
||||
slice_ = items[start:stop + 1 if stop >= 0 else None]
|
||||
if withscores:
|
||||
return [(k.encode(), v) for k, v in slice_]
|
||||
return [k.encode() for k, _ in slice_]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_increments_total_count():
|
||||
"""record 후 total_count == 1."""
|
||||
redis = FakeRedis()
|
||||
collector = MetricsCollector(redis)
|
||||
result = make_result(source="faq")
|
||||
await collector.record("tenant-1", result)
|
||||
assert redis._pipeline._data.get("tenant:tenant-1:metrics", {}).get("total_count", 0) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_faq_increments_faq_hit_count():
|
||||
"""source='faq'인 result record 후 faq_hit_count == 1."""
|
||||
redis = FakeRedis()
|
||||
collector = MetricsCollector(redis)
|
||||
await collector.record("tenant-1", make_result(source="faq"))
|
||||
counts = redis._pipeline._data.get("tenant:tenant-1:metrics", {})
|
||||
assert counts.get("faq_hit_count", 0) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_timeout_increments_timeout_count():
|
||||
"""is_timeout=True인 result record 후 timeout_count == 1."""
|
||||
redis = FakeRedis()
|
||||
collector = MetricsCollector(redis)
|
||||
await collector.record("tenant-1", make_result(is_timeout=True))
|
||||
counts = redis._pipeline._data.get("tenant:tenant-1:metrics", {})
|
||||
assert counts.get("timeout_count", 0) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_overview_returns_rates():
|
||||
"""total=10, faq=4 → faq_hit_rate == 40.0."""
|
||||
redis = FakeRedis()
|
||||
collector = MetricsCollector(redis)
|
||||
|
||||
# 수동으로 pipeline 데이터 설정
|
||||
redis._pipeline._data["tenant:tenant-1:metrics"] = {
|
||||
"total_count": 10,
|
||||
"faq_hit_count": 4,
|
||||
"rag_hit_count": 2,
|
||||
"fallback_count": 3,
|
||||
"timeout_count": 1,
|
||||
"response_ms_sum": 1000,
|
||||
}
|
||||
|
||||
overview = await collector.get_overview("tenant-1")
|
||||
assert overview["rates"]["faq_hit_rate"] == 40.0
|
||||
@@ -0,0 +1,68 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.middleware import tenanted_query, system_query, TenantMiddleware
|
||||
from app.models.knowledge import FAQ
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_exempt_no_tenant_required(client):
|
||||
"""GET /health → 200."""
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_endpoint_exempt(client):
|
||||
"""POST /api/admin/token → 400이 아닌 응답 (tenant 오류 아님)."""
|
||||
response = await client.post("/api/admin/token")
|
||||
# 엔드포인트가 없으면 404, 있으면 422/200 — 어떤 경우든 400(tenant_required)이 아님
|
||||
assert response.status_code != 400 or response.json().get("error") != "tenant_required"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_request_without_auth_returns_401(client):
|
||||
"""GET /api/admin/faqs (인증 없음) → 401 (admin 경로는 미들웨어 exempt, JWT 필요)."""
|
||||
response = await client.get("/api/admin/faqs")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_tenanted_query_without_tenant_id_raises_runtime_error():
|
||||
"""tenanted_query(select(FAQ), FAQ, None) → RuntimeError 발생."""
|
||||
with pytest.raises(RuntimeError):
|
||||
tenanted_query(select(FAQ), FAQ, None)
|
||||
|
||||
|
||||
def test_tenanted_query_with_tenant_id_adds_filter():
|
||||
"""생성된 SQL에 'tenant_id' 포함 확인."""
|
||||
query = tenanted_query(select(FAQ), FAQ, "test-tenant-id")
|
||||
sql = str(query.compile())
|
||||
assert "tenant_id" in sql
|
||||
|
||||
|
||||
def test_tenanted_query_with_empty_string_raises():
|
||||
"""tenanted_query(select(FAQ), FAQ, '') → RuntimeError 발생."""
|
||||
with pytest.raises(RuntimeError):
|
||||
tenanted_query(select(FAQ), FAQ, "")
|
||||
|
||||
|
||||
def test_system_query_has_no_tenant_filter():
|
||||
"""system_query(q) == q (변경 없음)."""
|
||||
q = select(FAQ)
|
||||
result = system_query(q)
|
||||
assert result is q
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_state_has_tenant_id_after_middleware(client):
|
||||
"""X-Tenant-Slug 헤더 포함 요청 시 request.state.tenant_id 주입 확인."""
|
||||
# 미들웨어가 tenant_id를 설정하면 400 대신 다른 응답 코드가 나와야 함
|
||||
response = await client.get(
|
||||
"/api/admin/faqs",
|
||||
headers={"X-Tenant-Slug": "test-tenant"}
|
||||
)
|
||||
# tenant_required 오류가 아니어야 함 (404 또는 다른 응답)
|
||||
assert response.status_code != 400 or response.json().get("error") != "tenant_required"
|
||||
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
from sqlalchemy import inspect, String, VARCHAR
|
||||
|
||||
from app.models.tenant import Tenant, TenantConfig
|
||||
from app.models.admin import SystemAdmin, AdminUser, AdminRole
|
||||
from app.models.knowledge import FAQ, Document, CrawlerURL
|
||||
from app.models.complaint import ComplaintLog
|
||||
from app.models.moderation import UserRestriction, RestrictionLevel
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
TENANT_REQUIRED_MODELS = [
|
||||
TenantConfig, FAQ, Document, CrawlerURL, ComplaintLog, UserRestriction, AuditLog, AdminUser
|
||||
]
|
||||
|
||||
|
||||
def get_column(model, col_name):
|
||||
"""모델에서 컬럼 객체 반환."""
|
||||
for col in model.__table__.columns:
|
||||
if col.name == col_name:
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
def test_all_tenant_models_have_tenant_id():
|
||||
"""8개 모델 모두 tenant_id 컬럼 포함 확인."""
|
||||
for model in TENANT_REQUIRED_MODELS:
|
||||
col = get_column(model, "tenant_id")
|
||||
assert col is not None, f"{model.__tablename__} must have tenant_id column"
|
||||
|
||||
|
||||
def test_system_admin_has_no_tenant_id():
|
||||
"""SystemAdmin 테이블에 tenant_id 없음 확인."""
|
||||
col = get_column(SystemAdmin, "tenant_id")
|
||||
assert col is None, "SystemAdmin must NOT have tenant_id column"
|
||||
|
||||
|
||||
def test_all_pk_are_uuid_string():
|
||||
"""모든 모델 PK 컬럼 타입이 VARCHAR(36) 또는 String."""
|
||||
all_models = [Tenant, TenantConfig, SystemAdmin, AdminUser, FAQ, Document,
|
||||
CrawlerURL, ComplaintLog, UserRestriction, AuditLog]
|
||||
for model in all_models:
|
||||
pk_cols = [c for c in model.__table__.columns if c.primary_key]
|
||||
assert len(pk_cols) > 0, f"{model.__tablename__} has no PK"
|
||||
for col in pk_cols:
|
||||
col_type = str(col.type)
|
||||
assert "VARCHAR" in col_type or "CHAR" in col_type or isinstance(col.type, String), \
|
||||
f"{model.__tablename__}.{col.name} PK must be String/VARCHAR, got {col_type}"
|
||||
|
||||
|
||||
def test_admin_user_email_not_globally_unique():
|
||||
"""AdminUser.email 컬럼의 unique=False 확인."""
|
||||
col = get_column(AdminUser, "email")
|
||||
assert col is not None
|
||||
assert not col.unique, "AdminUser.email must NOT be globally unique (use tenant-scoped constraint)"
|
||||
|
||||
|
||||
def test_system_admin_email_globally_unique():
|
||||
"""SystemAdmin.email 컬럼의 unique=True 확인."""
|
||||
col = get_column(SystemAdmin, "email")
|
||||
assert col is not None
|
||||
assert col.unique, "SystemAdmin.email must be globally unique"
|
||||
|
||||
|
||||
def test_document_is_active_default_false():
|
||||
"""Document.is_active 기본값이 False 확인."""
|
||||
col = get_column(Document, "is_active")
|
||||
assert col is not None
|
||||
assert col.default.arg is False, "Document.is_active default must be False"
|
||||
|
||||
|
||||
def test_complaint_log_has_request_id():
|
||||
"""ComplaintLog에 request_id 컬럼 존재 확인."""
|
||||
col = get_column(ComplaintLog, "request_id")
|
||||
assert col is not None, "ComplaintLog must have request_id column"
|
||||
|
||||
|
||||
def test_audit_log_has_required_columns():
|
||||
"""AuditLog에 actor_id, actor_type, action, tenant_id 확인."""
|
||||
required = ["actor_id", "actor_type", "action", "tenant_id"]
|
||||
for col_name in required:
|
||||
col = get_column(AuditLog, col_name)
|
||||
assert col is not None, f"AuditLog must have {col_name} column"
|
||||
|
||||
|
||||
def test_user_restriction_level_range():
|
||||
"""RestrictionLevel.NORMAL==0, BLOCKED==5."""
|
||||
assert RestrictionLevel.NORMAL == 0
|
||||
assert RestrictionLevel.BLOCKED == 5
|
||||
|
||||
|
||||
def test_admin_role_values():
|
||||
"""AdminRole 값 집합 == {'admin','editor','viewer','readonly_api'}."""
|
||||
expected = {"admin", "editor", "viewer", "readonly_api"}
|
||||
actual = {role.value for role in AdminRole}
|
||||
assert actual == expected
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
악성·반복 민원 제한 서비스 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.services.moderation import ModerationService, ModerationResult
|
||||
from app.models.moderation import UserRestriction, RestrictionLevel
|
||||
|
||||
|
||||
def make_db(restriction=None):
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=restriction)
|
||||
db.execute.return_value = scalar
|
||||
return db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_normal_user_is_allowed():
|
||||
"""제한 없는 사용자 → 허용."""
|
||||
db = make_db(restriction=None)
|
||||
service = ModerationService(db)
|
||||
result = await service.check("t1", "user-1")
|
||||
assert result.allowed is True
|
||||
assert result.level == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_blocked_user_is_denied():
|
||||
"""Level 5 (BLOCKED) → 차단."""
|
||||
r = MagicMock()
|
||||
r.level = RestrictionLevel.BLOCKED
|
||||
r.expires_at = None
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
result = await service.check("t1", "user-block")
|
||||
assert result.allowed is False
|
||||
assert result.level == RestrictionLevel.BLOCKED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_suspended_user_is_denied():
|
||||
"""Level 4 (SUSPENDED) → 편집장 확인 전 차단."""
|
||||
r = MagicMock()
|
||||
r.level = RestrictionLevel.SUSPENDED
|
||||
r.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
result = await service.check("t1", "user-sus")
|
||||
assert result.allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_warning_user_has_delay():
|
||||
"""Level 2 (WARNING) → 허용 + 30초 지연."""
|
||||
r = MagicMock()
|
||||
r.level = RestrictionLevel.WARNING
|
||||
r.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
result = await service.check("t1", "user-warn")
|
||||
assert result.allowed is True
|
||||
assert result.delay_seconds == 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_increases_level():
|
||||
"""레벨 상승: NORMAL → 다음 레벨."""
|
||||
r = MagicMock(spec=UserRestriction)
|
||||
r.level = RestrictionLevel.NORMAL
|
||||
r.expires_at = None
|
||||
r.auto_applied = True
|
||||
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
new_level = await service.escalate("t1", "user-1", "반복 민원")
|
||||
|
||||
assert new_level == RestrictionLevel.NORMAL + 1
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_stops_at_suspended():
|
||||
"""Level 4 이상 자동 상승 금지."""
|
||||
r = MagicMock(spec=UserRestriction)
|
||||
r.level = RestrictionLevel.SUSPENDED
|
||||
r.expires_at = None
|
||||
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
new_level = await service.escalate("t1", "user-1")
|
||||
|
||||
# Level 4에서 멈춤
|
||||
assert new_level == RestrictionLevel.SUSPENDED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_resets_level():
|
||||
"""수동 해제 → level 0."""
|
||||
r = MagicMock(spec=UserRestriction)
|
||||
r.level = RestrictionLevel.SUSPENDED
|
||||
r.expires_at = None
|
||||
r.auto_applied = True
|
||||
r.applied_by = None
|
||||
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
await service.release("t1", "user-1", applied_by="editor-1")
|
||||
|
||||
assert r.level == RestrictionLevel.NORMAL
|
||||
assert r.applied_by == "editor-1"
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_expired_restriction_resets():
|
||||
"""만료된 제한 → 자동 해제."""
|
||||
r = MagicMock(spec=UserRestriction)
|
||||
r.level = RestrictionLevel.WARNING # Level 2
|
||||
r.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # 만료
|
||||
r.auto_applied = True
|
||||
r.applied_by = None
|
||||
|
||||
db = make_db(restriction=r)
|
||||
|
||||
service = ModerationService(db)
|
||||
result = await service.check("t1", "user-expired")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.level == 0
|
||||
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
|
||||
from app.providers.llm import NullLLMProvider
|
||||
from app.providers.embedding import NotImplementedEmbeddingProvider
|
||||
from app.providers import get_llm_provider, get_embedding_provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_llm_provider_returns_none():
|
||||
"""NullLLMProvider().generate(...) == None."""
|
||||
provider = NullLLMProvider()
|
||||
result = await provider.generate("system", "user", [])
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_llm_provider_none_config():
|
||||
"""get_llm_provider({'LLM_PROVIDER':'none'}) == NullLLMProvider 인스턴스."""
|
||||
provider = get_llm_provider({"LLM_PROVIDER": "none"})
|
||||
assert isinstance(provider, NullLLMProvider)
|
||||
|
||||
|
||||
def test_get_llm_provider_unknown_raises():
|
||||
"""get_llm_provider({'LLM_PROVIDER':'xyz'}) → ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
get_llm_provider({"LLM_PROVIDER": "xyz"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_implemented_embedding_raises_on_embed():
|
||||
"""NotImplementedEmbeddingProvider().embed(['test']) → NotImplementedError."""
|
||||
provider = NotImplementedEmbeddingProvider()
|
||||
with pytest.raises(NotImplementedError):
|
||||
await provider.embed(["test"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_implemented_embedding_warmup_is_noop():
|
||||
""".warmup() 호출 시 예외 없이 통과."""
|
||||
provider = NotImplementedEmbeddingProvider()
|
||||
await provider.warmup() # 예외 없어야 함
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
RAGSearchService 단위 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.rag_search import RAGSearchService, RAG_SIMILARITY_THRESHOLD
|
||||
from app.providers.base import SearchResult
|
||||
|
||||
|
||||
def make_doc(tenant_id: str, filename: str = "안내문.txt") -> MagicMock:
|
||||
doc = MagicMock()
|
||||
doc.id = str(uuid4())
|
||||
doc.tenant_id = tenant_id
|
||||
doc.filename = filename
|
||||
doc.is_active = True
|
||||
doc.published_at = datetime(2026, 3, 1)
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_search_returns_results_above_threshold():
|
||||
"""유사도 ≥ 0.70 → RAGSearchResult 반환."""
|
||||
doc = make_doc("t1")
|
||||
doc_id = doc.id
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(
|
||||
text="여권은 민원과에서 발급합니다.",
|
||||
doc_id=f"{doc_id}_0",
|
||||
score=0.78,
|
||||
metadata={"doc_id": doc_id, "filename": "여권안내.txt"},
|
||||
)
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
service = RAGSearchService(embedding, vectordb, db)
|
||||
results = await service.search("t1", "여권 발급")
|
||||
|
||||
assert results is not None
|
||||
assert len(results) > 0
|
||||
assert results[0].score >= RAG_SIMILARITY_THRESHOLD
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_search_returns_none_when_no_match():
|
||||
"""매칭 없으면 None."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[])
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
service = RAGSearchService(embedding, vectordb, db)
|
||||
results = await service.search("t1", "알 수 없는 질문")
|
||||
|
||||
assert results is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_search_excludes_inactive_docs():
|
||||
"""is_active=False 문서 → 결과에서 제외."""
|
||||
doc_id = str(uuid4())
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.85,
|
||||
metadata={"doc_id": doc_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=None) # is_active 필터로 None
|
||||
db.execute.return_value = scalar
|
||||
|
||||
service = RAGSearchService(embedding, vectordb, db)
|
||||
results = await service.search("t1", "질문")
|
||||
|
||||
assert results is None
|
||||
|
||||
|
||||
def test_rag_build_answer_includes_citation():
|
||||
"""응답에 출처(문서명·날짜) 포함."""
|
||||
doc = make_doc("t1", "여권발급안내.txt")
|
||||
|
||||
embedding = AsyncMock()
|
||||
vectordb = AsyncMock()
|
||||
db = AsyncMock()
|
||||
|
||||
from app.services.rag_search import RAGSearchResult
|
||||
rag_result = RAGSearchResult(
|
||||
chunk_text="여권 발급은 민원과에서 처리합니다.",
|
||||
doc=doc,
|
||||
score=0.78,
|
||||
)
|
||||
|
||||
service = RAGSearchService(embedding, vectordb, db)
|
||||
answer = service.build_answer("여권 질문", [rag_result])
|
||||
|
||||
assert "출처" in answer
|
||||
assert "여권발급안내.txt" in answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_deduplicates_same_doc_chunks():
|
||||
"""같은 문서의 여러 청크 → 최고 점수 1개만."""
|
||||
doc_id = str(uuid4())
|
||||
doc = make_doc("t1")
|
||||
doc.id = doc_id
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="청크1", doc_id=f"{doc_id}_0", score=0.80,
|
||||
metadata={"doc_id": doc_id}),
|
||||
SearchResult(text="청크2", doc_id=f"{doc_id}_1", score=0.75,
|
||||
metadata={"doc_id": doc_id}),
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
service = RAGSearchService(embedding, vectordb, db)
|
||||
results = await service.search("t1", "질문")
|
||||
|
||||
# 같은 문서는 1개만
|
||||
doc_ids = [r.doc.id for r in results]
|
||||
assert len(doc_ids) == len(set(doc_ids))
|
||||
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_ok_when_all_services_up(client):
|
||||
"""DB/Redis mock 정상 → GET /ready → 200, ready=True."""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.ping = AsyncMock(return_value=True)
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("app.routers.health.aioredis.from_url", return_value=mock_redis):
|
||||
response = await client.get("/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ready"] is True
|
||||
assert data["checks"]["db"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_503_when_db_down(client):
|
||||
"""DB mock 오류 → GET /ready → 503, ready=False."""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.ping = AsyncMock(return_value=True)
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
# DB 연결 실패 시뮬레이션
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.execute = AsyncMock(side_effect=Exception("DB connection failed"))
|
||||
|
||||
with patch("app.routers.health.aioredis.from_url", return_value=mock_redis), \
|
||||
patch("app.routers.health.AsyncSessionLocal", return_value=mock_session):
|
||||
response = await client.get("/ready")
|
||||
|
||||
assert response.status_code == 503
|
||||
data = response.json()
|
||||
assert data["ready"] is False
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
POST /skill/{tenant_slug} — 카카오 스킬 API 테스트.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
def make_kakao_body(utterance: str, user_id: str = "kakao-user-1") -> dict:
|
||||
return {
|
||||
"userRequest": {
|
||||
"utterance": utterance,
|
||||
"user": {"id": user_id},
|
||||
},
|
||||
"action": {"params": {}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_returns_kakao_format(client):
|
||||
"""스킬 응답이 카카오 포맷(version, template.outputs)을 포함한다."""
|
||||
response = await client.post(
|
||||
"/skill/test-tenant",
|
||||
json=make_kakao_body("여권 발급 방법"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["version"] == "2.0"
|
||||
assert "template" in data
|
||||
assert "outputs" in data["template"]
|
||||
assert len(data["template"]["outputs"]) > 0
|
||||
assert "simpleText" in data["template"]["outputs"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_tier_d_fallback_returns_200(client):
|
||||
"""FAQ 없어도 Tier D 응답으로 200 반환."""
|
||||
response = await client.post(
|
||||
"/skill/test-tenant",
|
||||
json=make_kakao_body("이상한 질문"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
text = data["template"]["outputs"][0]["simpleText"]["text"]
|
||||
assert len(text) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_utterance_text_in_response(client):
|
||||
"""응답 text 필드가 비어있지 않다."""
|
||||
response = await client.post(
|
||||
"/skill/dongducheon",
|
||||
json=make_kakao_body("담당부서 알려주세요"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
text = response.json()["template"]["outputs"][0]["simpleText"]["text"]
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_user_key_is_hashed(client):
|
||||
"""user_key는 원본 ID가 아닌 해시값(16자리)으로 처리된다 (응답에 노출 안 됨)."""
|
||||
# 개인정보(원본 카카오 ID)가 응답 바디에 노출되지 않는지 확인
|
||||
user_id = "real-kakao-id-12345"
|
||||
response = await client.post(
|
||||
"/skill/test-tenant",
|
||||
json=make_kakao_body("질문", user_id=user_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_text = response.text
|
||||
assert user_id not in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_masked_pii_not_in_answer(client):
|
||||
"""개인정보가 포함된 발화도 정상 처리 (마스킹 후 라우팅)."""
|
||||
response = await client.post(
|
||||
"/skill/test-tenant",
|
||||
json=make_kakao_body("전화번호 010-1234-5678로 연락해주세요"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# 마스킹된 발화로 처리되므로 응답은 정상
|
||||
data = response.json()
|
||||
assert "version" in data
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Tier A 라우팅 통합 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.routing import ResponseRouter, RoutingResult
|
||||
from app.providers.base import SearchResult
|
||||
|
||||
|
||||
def make_router(embedding=None, vectordb=None) -> ResponseRouter:
|
||||
providers = {}
|
||||
if embedding:
|
||||
providers["embedding"] = embedding
|
||||
if vectordb:
|
||||
providers["vectordb"] = vectordb
|
||||
return ResponseRouter(
|
||||
tenant_config={
|
||||
"phone_number": "031-860-2000",
|
||||
"fallback_dept": "민원과",
|
||||
"tenant_name": "동두천시",
|
||||
},
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_a_returns_when_faq_found():
|
||||
"""벡터DB에서 FAQ 매칭 → Tier A 반환."""
|
||||
faq_id = str(uuid4())
|
||||
faq = MagicMock()
|
||||
faq.id = faq_id
|
||||
faq.answer = "여권은 민원과에서 발급합니다."
|
||||
faq.question = "여권 발급 방법"
|
||||
faq.is_active = True
|
||||
faq.hit_count = 0
|
||||
faq.updated_at = None
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="여권 발급 방법\n여권은 민원과에서 발급합니다.",
|
||||
doc_id=f"{faq_id}_0", score=0.92, metadata={"faq_id": faq_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=faq)
|
||||
db.execute.return_value = scalar
|
||||
db.get = AsyncMock(return_value=faq)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "여권 발급 방법", "user-1", db=db)
|
||||
|
||||
assert result.tier == "A"
|
||||
assert result.source == "faq"
|
||||
assert result.answer == faq.answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_a_skipped_when_no_match():
|
||||
"""매칭 없으면 Tier D 반환."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[]) # 빈 결과
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "모르는 질문", "user-1", db=db)
|
||||
|
||||
assert result.tier == "D"
|
||||
assert result.source == "fallback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_result_has_faq_id():
|
||||
"""Tier A 결과에 faq_id가 포함된다."""
|
||||
faq_id = str(uuid4())
|
||||
faq = MagicMock()
|
||||
faq.id = faq_id
|
||||
faq.answer = "답변"
|
||||
faq.question = "질문"
|
||||
faq.is_active = True
|
||||
faq.hit_count = 0
|
||||
faq.updated_at = None
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.90,
|
||||
metadata={"faq_id": faq_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=faq)
|
||||
db.execute.return_value = scalar
|
||||
db.get = AsyncMock(return_value=faq)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
assert result.faq_id == faq_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_result_tier_d_has_fallback_info():
|
||||
"""Tier D 결과에 담당부서·전화번호가 포함된다."""
|
||||
router = make_router()
|
||||
result = await router.route("tenant-1", "알 수 없는 질문", "user-1", db=None)
|
||||
|
||||
assert result.tier == "D"
|
||||
assert "031-860-2000" in result.answer
|
||||
assert "민원과" in result.answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_tier_d_is_timeout():
|
||||
"""타임아웃 발생 시 Tier D + is_timeout=True."""
|
||||
import asyncio
|
||||
|
||||
async def slow_embed(texts):
|
||||
await asyncio.sleep(10) # 타임아웃보다 긴 대기
|
||||
return [[0.0] * 768]
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(side_effect=slow_embed)
|
||||
|
||||
vectordb = AsyncMock()
|
||||
|
||||
# 타임아웃을 매우 짧게 설정
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
router.TIMEOUT_MS = 50 # 50ms
|
||||
|
||||
db = AsyncMock()
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
assert result.tier == "D"
|
||||
assert result.is_timeout is True
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Tier B RAG 라우팅 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.routing import ResponseRouter
|
||||
from app.providers.base import SearchResult
|
||||
|
||||
|
||||
def make_router(embedding=None, vectordb=None) -> ResponseRouter:
|
||||
providers = {}
|
||||
if embedding:
|
||||
providers["embedding"] = embedding
|
||||
if vectordb:
|
||||
providers["vectordb"] = vectordb
|
||||
return ResponseRouter(
|
||||
tenant_config={
|
||||
"phone_number": "031-860-2000",
|
||||
"fallback_dept": "민원과",
|
||||
"tenant_name": "동두천시",
|
||||
},
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
|
||||
def make_doc(doc_id: str) -> MagicMock:
|
||||
doc = MagicMock()
|
||||
doc.id = doc_id
|
||||
doc.filename = "안내문.txt"
|
||||
doc.is_active = True
|
||||
doc.published_at = datetime(2026, 3, 1)
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_b_returns_when_doc_found():
|
||||
"""문서 RAG 매칭 → Tier B 반환."""
|
||||
doc_id = str(uuid4())
|
||||
doc = make_doc(doc_id)
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="여권은 민원과에서 발급합니다.", doc_id=f"{doc_id}_0",
|
||||
score=0.75, metadata={"doc_id": doc_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "여권 발급 방법", "user-1", db=db)
|
||||
|
||||
assert result.tier == "B"
|
||||
assert result.source == "rag"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_b_skipped_when_no_doc_match():
|
||||
"""문서 매칭 없으면 Tier D."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[])
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "모르는 질문", "user-1", db=db)
|
||||
|
||||
assert result.tier == "D"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_b_result_has_doc_name():
|
||||
"""Tier B 결과에 doc_name 포함."""
|
||||
doc_id = str(uuid4())
|
||||
doc = make_doc(doc_id)
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.72,
|
||||
metadata={"doc_id": doc_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
assert result.doc_name is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_a_takes_priority_over_tier_b():
|
||||
"""FAQ 유사도 ≥ 0.85 → Tier A (Tier B 미실행)."""
|
||||
faq_id = str(uuid4())
|
||||
faq = MagicMock()
|
||||
faq.id = faq_id
|
||||
faq.answer = "FAQ 답변"
|
||||
faq.question = "질문"
|
||||
faq.is_active = True
|
||||
faq.hit_count = 0
|
||||
faq.updated_at = None
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
# FAQ 검색: 높은 유사도
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="Q\nA", doc_id=f"{faq_id}_0", score=0.92,
|
||||
metadata={"faq_id": faq_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=faq)
|
||||
db.execute.return_value = scalar
|
||||
db.get = AsyncMock(return_value=faq)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb)
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
# FAQ가 먼저 매칭되면 Tier A
|
||||
assert result.tier == "A"
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Tier C — LLM 기반 재서술 라우팅 테스트.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.routing import ResponseRouter
|
||||
from app.providers.llm import NullLLMProvider
|
||||
from app.providers.base import SearchResult
|
||||
|
||||
|
||||
def make_doc(doc_id: str) -> MagicMock:
|
||||
doc = MagicMock()
|
||||
doc.id = doc_id
|
||||
doc.filename = "안내문.txt"
|
||||
doc.is_active = True
|
||||
doc.published_at = datetime(2026, 3, 1)
|
||||
return doc
|
||||
|
||||
|
||||
def make_router(embedding=None, vectordb=None, llm=None) -> ResponseRouter:
|
||||
providers = {}
|
||||
if embedding:
|
||||
providers["embedding"] = embedding
|
||||
if vectordb:
|
||||
providers["vectordb"] = vectordb
|
||||
if llm:
|
||||
providers["llm"] = llm
|
||||
return ResponseRouter(
|
||||
tenant_config={
|
||||
"phone_number": "031-860-2000",
|
||||
"fallback_dept": "민원과",
|
||||
"tenant_name": "동두천시",
|
||||
},
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_c_returns_llm_answer():
|
||||
"""LLM 활성화 + RAG 근거 있음 → Tier C 반환."""
|
||||
doc_id = str(uuid4())
|
||||
doc = make_doc(doc_id)
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="여권은 민원과에서 발급합니다.", doc_id=f"{doc_id}_0",
|
||||
score=0.75, metadata={"doc_id": doc_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
llm = AsyncMock()
|
||||
llm.generate = AsyncMock(return_value="여권 발급은 민원과(031-860-2000)에서 신청하시면 됩니다.")
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
|
||||
result = await router.route("tenant-1", "여권 어디서 받아요", "user-1", db=db)
|
||||
|
||||
assert result.tier == "C"
|
||||
assert result.source == "llm"
|
||||
assert "여권" in result.answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_c_skipped_when_llm_is_null():
|
||||
"""NullLLMProvider → Tier C 스킵 → Tier B 또는 D."""
|
||||
doc_id = str(uuid4())
|
||||
doc = make_doc(doc_id)
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.75,
|
||||
metadata={"doc_id": doc_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
llm = NullLLMProvider() # none 기본값
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
# NullLLM → Tier C 스킵 → Tier B (RAG 매칭 있으므로)
|
||||
assert result.tier in ("B", "D")
|
||||
assert result.tier != "C"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_c_skipped_when_no_rag_context():
|
||||
"""RAG 근거 없음 → LLM 미호출 (할루시네이션 방지)."""
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[]) # 근거 없음
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
llm = AsyncMock()
|
||||
llm.generate = AsyncMock(return_value="LLM 답변")
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
# 근거 없으면 LLM 미호출 → Tier D
|
||||
assert result.tier == "D"
|
||||
llm.generate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tier_c_falls_back_to_d_when_llm_fails():
|
||||
"""LLM 실패(None 반환) → Tier D 폴백."""
|
||||
doc_id = str(uuid4())
|
||||
doc = make_doc(doc_id)
|
||||
|
||||
embedding = AsyncMock()
|
||||
embedding.embed = AsyncMock(return_value=[[0.1] * 768])
|
||||
|
||||
vectordb = AsyncMock()
|
||||
vectordb.search = AsyncMock(return_value=[
|
||||
SearchResult(text="내용", doc_id=f"{doc_id}_0", score=0.75,
|
||||
metadata={"doc_id": doc_id})
|
||||
])
|
||||
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
scalar = MagicMock()
|
||||
scalar.scalar_one_or_none = MagicMock(return_value=doc)
|
||||
db.execute.return_value = scalar
|
||||
|
||||
llm = AsyncMock()
|
||||
llm.generate = AsyncMock(return_value=None) # LLM 실패
|
||||
|
||||
router = make_router(embedding=embedding, vectordb=vectordb, llm=llm)
|
||||
result = await router.route("tenant-1", "질문", "user-1", db=db)
|
||||
|
||||
# LLM None → Tier C 없음 → Tier B (RAG 있으므로)
|
||||
assert result.tier in ("B", "D")
|
||||
assert result.tier != "C"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_not_called_without_context_chunks():
|
||||
"""AnthropicLLMProvider: context_chunks 비어있으면 None."""
|
||||
from app.providers.llm_anthropic import AnthropicLLMProvider
|
||||
provider = AnthropicLLMProvider(api_key="test-key")
|
||||
result = await provider.generate("system", "user", context_chunks=[])
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user