Initial commit: import from sinmb79/Gov-chat-bot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
airkjw
2026-03-26 12:49:43 +09:00
commit a16c972dbb
104 changed files with 8063 additions and 0 deletions
View File
+56
View File
@@ -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()
+114
View File
@@ -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
+79
View File
@@ -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)
+115
View File
@@ -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"
+65
View File
@@ -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"]
+128
View File
@@ -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
+92
View File
@@ -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
+123
View File
@@ -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"
+109
View File
@@ -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
+142
View File
@@ -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
+49
View File
@@ -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
+60
View File
@@ -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
+60
View File
@@ -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
+114
View File
@@ -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
+68
View File
@@ -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"
+95
View File
@@ -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
+141
View File
@@ -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
+40
View File
@@ -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() # 예외 없어야 함
+150
View File
@@ -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))
+40
View File
@@ -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
+83
View File
@@ -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
+151
View File
@@ -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
+144
View File
@@ -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"
+164
View File
@@ -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