276 lines
9.5 KiB
Python
276 lines
9.5 KiB
Python
|
|
"""
|
|||
|
|
测试知识库路由的变更
|
|||
|
|
文件: server/routers/knowledge_router.py
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
project_root = Path(__file__).parent.parent
|
|||
|
|
sys.path.insert(0, str(project_root))
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
|||
|
|
from fastapi import UploadFile
|
|||
|
|
from io import BytesIO
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestKnowledgeRouterChanges:
|
|||
|
|
"""测试 knowledge_router.py 的变更"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_add_documents_minio_upload(self):
|
|||
|
|
"""测试上传文档后自动上传到 MinIO"""
|
|||
|
|
# 模拟成功处理的文件项
|
|||
|
|
success_items = [
|
|||
|
|
{
|
|||
|
|
"status": "done",
|
|||
|
|
"path": "/tmp/test_file.pdf",
|
|||
|
|
"filename": "test_file.pdf",
|
|||
|
|
"file_type": "application/pdf",
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# 模拟 aupload_file_to_minio 函数
|
|||
|
|
with patch("server.routers.knowledge_router.aupload_file_to_minio") as mock_upload:
|
|||
|
|
mock_upload.return_value = "https://minio.example.com/ref-test-db/test_file.pdf"
|
|||
|
|
|
|||
|
|
# 模拟 aiofiles.open
|
|||
|
|
with patch("server.routers.knowledge_router.aiofiles.open") as mock_open:
|
|||
|
|
mock_file = AsyncMock()
|
|||
|
|
mock_file.read = AsyncMock(return_value=b"PDF content")
|
|||
|
|
mock_open.return_value.__aenter__.return_value = mock_file
|
|||
|
|
|
|||
|
|
# 模拟上传逻辑
|
|||
|
|
for success_item in success_items:
|
|||
|
|
# 读取文件
|
|||
|
|
async with mock_open(success_item["path"], "rb") as f:
|
|||
|
|
file_bytes = await f.read()
|
|||
|
|
|
|||
|
|
# 上传到 MinIO
|
|||
|
|
refdb = "test_db".replace("_", "-")
|
|||
|
|
url = await mock_upload(
|
|||
|
|
f"ref-{refdb}",
|
|||
|
|
success_item["filename"],
|
|||
|
|
file_bytes,
|
|||
|
|
success_item["file_type"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert url is not None
|
|||
|
|
assert "ref-test-db" in url
|
|||
|
|
print(f"✓ MinIO 上传模拟成功: {url}")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_delete_document_minio_cleanup(self):
|
|||
|
|
"""测试删除文档时同时删除 MinIO 中的文件"""
|
|||
|
|
db_id = "test_database"
|
|||
|
|
doc_id = "doc_12345"
|
|||
|
|
file_name = "test_document.pdf"
|
|||
|
|
|
|||
|
|
# 模拟 knowledge_base.get_file_basic_info
|
|||
|
|
with patch("server.routers.knowledge_router.knowledge_base") as mock_kb:
|
|||
|
|
mock_kb.get_file_basic_info = AsyncMock(
|
|||
|
|
return_value={"meta": {"filename": file_name}}
|
|||
|
|
)
|
|||
|
|
mock_kb.delete_file = AsyncMock()
|
|||
|
|
|
|||
|
|
# 模拟 MinIO 客户端
|
|||
|
|
with patch("server.routers.knowledge_router.get_minio_client") as mock_get_client:
|
|||
|
|
mock_client = Mock()
|
|||
|
|
mock_client.adelete_file = AsyncMock(return_value=True)
|
|||
|
|
mock_get_client.return_value = mock_client
|
|||
|
|
|
|||
|
|
# 执行删除逻辑
|
|||
|
|
file_meta_info = await mock_kb.get_file_basic_info(db_id, doc_id)
|
|||
|
|
file_name_result = file_meta_info.get("meta", {}).get("filename")
|
|||
|
|
|
|||
|
|
await mock_client.adelete_file("ref-" + db_id.replace("_", "-"), file_name_result)
|
|||
|
|
await mock_kb.delete_file(db_id, doc_id)
|
|||
|
|
|
|||
|
|
# 验证调用
|
|||
|
|
mock_kb.get_file_basic_info.assert_called_once_with(db_id, doc_id)
|
|||
|
|
mock_client.adelete_file.assert_called_once_with(
|
|||
|
|
"ref-test-database", file_name
|
|||
|
|
)
|
|||
|
|
mock_kb.delete_file.assert_called_once_with(db_id, doc_id)
|
|||
|
|
print(f"✓ 删除文档时 MinIO 清理逻辑验证成功")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_download_document_streaming_response(self):
|
|||
|
|
"""测试文档下载的流式响应"""
|
|||
|
|
import asyncio
|
|||
|
|
|
|||
|
|
# 模拟 MinIO 响应对象
|
|||
|
|
class MockMinioResponse:
|
|||
|
|
def __init__(self, data):
|
|||
|
|
self.data = data
|
|||
|
|
self.position = 0
|
|||
|
|
|
|||
|
|
def read(self, size):
|
|||
|
|
"""同步读取方法"""
|
|||
|
|
if self.position >= len(self.data):
|
|||
|
|
return b""
|
|||
|
|
chunk = self.data[self.position : self.position + size]
|
|||
|
|
self.position += size
|
|||
|
|
return chunk
|
|||
|
|
|
|||
|
|
def close(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def release_conn(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
test_data = b"A" * 20000 # 20KB 测试数据
|
|||
|
|
mock_response = MockMinioResponse(test_data)
|
|||
|
|
|
|||
|
|
# 模拟流式生成器
|
|||
|
|
async def minio_stream():
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
chunk = await asyncio.to_thread(mock_response.read, 8192)
|
|||
|
|
if not chunk:
|
|||
|
|
break
|
|||
|
|
yield chunk
|
|||
|
|
finally:
|
|||
|
|
mock_response.close()
|
|||
|
|
mock_response.release_conn()
|
|||
|
|
|
|||
|
|
# 收集所有块
|
|||
|
|
chunks = []
|
|||
|
|
async for chunk in minio_stream():
|
|||
|
|
chunks.append(chunk)
|
|||
|
|
|
|||
|
|
# 验证
|
|||
|
|
result_data = b"".join(chunks)
|
|||
|
|
assert len(result_data) == len(test_data)
|
|||
|
|
assert result_data == test_data
|
|||
|
|
print(f"✓ 流式下载响应验证成功(数据大小: {len(result_data)} 字节,{len(chunks)} 个块)")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_upload_file_hash_calculation(self):
|
|||
|
|
"""测试文件上传时的异步哈希计算"""
|
|||
|
|
from src.knowledge.utils.kb_utils import calculate_content_hash
|
|||
|
|
|
|||
|
|
# 模拟上传的文件内容
|
|||
|
|
file_content = b"Test file content for hashing"
|
|||
|
|
|
|||
|
|
# 异步计算哈希
|
|||
|
|
content_hash = await calculate_content_hash(file_content)
|
|||
|
|
|
|||
|
|
assert content_hash is not None
|
|||
|
|
assert len(content_hash) == 64 # SHA-256
|
|||
|
|
print(f"✓ 上传文件哈希计算成功: {content_hash}")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_file_existence_check(self):
|
|||
|
|
"""测试文件存在性检查的异步调用"""
|
|||
|
|
db_id = "test_db"
|
|||
|
|
content_hash = "a" * 64
|
|||
|
|
|
|||
|
|
# 模拟 knowledge_base
|
|||
|
|
with patch("server.routers.knowledge_router.knowledge_base") as mock_kb:
|
|||
|
|
mock_kb.file_existed_in_db = AsyncMock(return_value=False)
|
|||
|
|
|
|||
|
|
# 执行检查
|
|||
|
|
file_exists = await mock_kb.file_existed_in_db(db_id, content_hash)
|
|||
|
|
|
|||
|
|
assert file_exists is False
|
|||
|
|
mock_kb.file_existed_in_db.assert_called_once_with(db_id, content_hash)
|
|||
|
|
print(f"✓ 异步文件存在性检查验证成功")
|
|||
|
|
|
|||
|
|
def test_upload_file_fixed_salt_logic(self):
|
|||
|
|
"""测试上传文件时的固定 salt 逻辑"""
|
|||
|
|
from src.utils import hashstr
|
|||
|
|
|
|||
|
|
# 测试固定 salt 的文件命名
|
|||
|
|
basename = "test_document"
|
|||
|
|
ext = ".pdf"
|
|||
|
|
|
|||
|
|
# 使用固定 salt
|
|||
|
|
filename1 = f"{basename}_{hashstr(basename, 4, with_salt=True, salt='fixed_salt')}{ext}".lower()
|
|||
|
|
filename2 = f"{basename}_{hashstr(basename, 4, with_salt=True, salt='fixed_salt')}{ext}".lower()
|
|||
|
|
|
|||
|
|
# 验证相同名称生成相同的哈希(因为 salt 固定)
|
|||
|
|
assert filename1 == filename2
|
|||
|
|
print(f"✓ 固定 salt 文件命名验证成功: {filename1}")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_streaming_response_error_handling(self):
|
|||
|
|
"""测试流式响应的错误处理"""
|
|||
|
|
import asyncio
|
|||
|
|
|
|||
|
|
class MockErrorResponse:
|
|||
|
|
def read(self, size):
|
|||
|
|
raise Exception("MinIO read error")
|
|||
|
|
|
|||
|
|
def close(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def release_conn(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
mock_response = MockErrorResponse()
|
|||
|
|
|
|||
|
|
async def minio_stream():
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
chunk = await asyncio.to_thread(mock_response.read, 8192)
|
|||
|
|
if not chunk:
|
|||
|
|
break
|
|||
|
|
yield chunk
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"✓ 错误被正确捕获: {e}")
|
|||
|
|
raise
|
|||
|
|
finally:
|
|||
|
|
mock_response.close()
|
|||
|
|
mock_response.release_conn()
|
|||
|
|
|
|||
|
|
# 验证错误处理
|
|||
|
|
with pytest.raises(Exception, match="MinIO read error"):
|
|||
|
|
async for chunk in minio_stream():
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
print(f"✓ 流式响应错误处理验证成功")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestMinIOIntegration:
|
|||
|
|
"""测试 MinIO 集成逻辑"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_bucket_naming_convention(self):
|
|||
|
|
"""测试 bucket 命名约定(ref-{db_id})"""
|
|||
|
|
db_id = "test_knowledge_base"
|
|||
|
|
expected_bucket = "ref-test-knowledge-base" # 下划线替换为连字符
|
|||
|
|
|
|||
|
|
refdb = db_id.replace("_", "-")
|
|||
|
|
actual_bucket = f"ref-{refdb}"
|
|||
|
|
|
|||
|
|
assert actual_bucket == expected_bucket
|
|||
|
|
print(f"✓ Bucket 命名约定正确: {db_id} -> {actual_bucket}")
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_concurrent_minio_uploads(self):
|
|||
|
|
"""测试并发上传到 MinIO"""
|
|||
|
|
import asyncio
|
|||
|
|
|
|||
|
|
# 模拟多个文件的并发上传
|
|||
|
|
async def mock_upload(file_id):
|
|||
|
|
await asyncio.sleep(0.1) # 模拟上传延迟
|
|||
|
|
return f"https://minio.example.com/bucket/file_{file_id}"
|
|||
|
|
|
|||
|
|
# 并发上传 5 个文件
|
|||
|
|
tasks = [mock_upload(i) for i in range(5)]
|
|||
|
|
results = await asyncio.gather(*tasks)
|
|||
|
|
|
|||
|
|
assert len(results) == 5
|
|||
|
|
for i, url in enumerate(results):
|
|||
|
|
assert f"file_{i}" in url
|
|||
|
|
|
|||
|
|
print(f"✓ 并发 MinIO 上传模拟成功,处理了 {len(results)} 个文件")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
exit_code = pytest.main([__file__, "-v", "-s", "--tb=short", "--color=yes"])
|
|||
|
|
sys.exit(exit_code)
|