test_handlers.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. """
  2. 向量搜索处理器单元测试
  3. 测试向量搜索应用层的命令和查询处理器。
  4. 使用 Mock 仓储隔离外部依赖。
  5. """
  6. import pytest
  7. from unittest.mock import AsyncMock, MagicMock
  8. from datetime import datetime
  9. from src.application.vector_search.handlers import (
  10. CreateDocumentHandler,
  11. UpdateDocumentHandler,
  12. DeleteDocumentHandler,
  13. SearchDocumentsHandler,
  14. GetDocumentHandler
  15. )
  16. from src.application.vector_search.commands import (
  17. CreateDocumentCommand,
  18. UpdateDocumentCommand,
  19. DeleteDocumentCommand
  20. )
  21. from src.application.vector_search.queries import (
  22. SearchDocumentsQuery,
  23. GetDocumentQuery
  24. )
  25. from src.application.shared.exceptions import (
  26. ResourceNotFoundException,
  27. ValidationException,
  28. ApplicationException
  29. )
  30. from src.domain.vector_search.entities import Document, SearchResult
  31. from src.domain.vector_search.value_objects import Vector, SearchQuery
  32. from src.domain.shared.value_objects import EntityId, Timestamp
  33. from src.domain.shared.exceptions import DomainException
  34. class TestCreateDocumentHandler:
  35. """测试创建文档处理器"""
  36. @pytest.fixture
  37. def mock_repository(self):
  38. """创建 Mock 仓储"""
  39. repository = AsyncMock()
  40. repository.save = AsyncMock()
  41. return repository
  42. @pytest.fixture
  43. def handler(self, mock_repository):
  44. """创建处理器实例"""
  45. return CreateDocumentHandler(mock_repository)
  46. @pytest.mark.asyncio
  47. async def test_handle_creates_document_successfully(self, handler, mock_repository):
  48. """测试成功创建文档"""
  49. # Arrange
  50. command = CreateDocumentCommand(
  51. content="Test document content",
  52. metadata={"source": "test"}
  53. )
  54. # Act
  55. document_id = await handler.handle(command)
  56. # Assert
  57. assert document_id is not None
  58. assert isinstance(document_id, str)
  59. assert len(document_id) > 0
  60. # 验证仓储的 save 方法被调用
  61. mock_repository.save.assert_called_once()
  62. # 获取传递给 save 的文档
  63. saved_document = mock_repository.save.call_args[0][0]
  64. assert isinstance(saved_document, Document)
  65. assert saved_document.content == "Test document content"
  66. assert saved_document.metadata == {"source": "test"}
  67. assert saved_document.embedding is None # 嵌入向量应该为 None
  68. @pytest.mark.asyncio
  69. async def test_handle_with_empty_metadata(self, handler, mock_repository):
  70. """测试使用空元数据创建文档"""
  71. # Arrange
  72. command = CreateDocumentCommand(
  73. content="Test content",
  74. metadata={}
  75. )
  76. # Act
  77. document_id = await handler.handle(command)
  78. # Assert
  79. assert document_id is not None
  80. mock_repository.save.assert_called_once()
  81. @pytest.mark.asyncio
  82. async def test_handle_raises_application_exception_on_repository_error(
  83. self, handler, mock_repository
  84. ):
  85. """测试仓储错误时抛出应用异常"""
  86. # Arrange
  87. command = CreateDocumentCommand(
  88. content="Test content",
  89. metadata={}
  90. )
  91. mock_repository.save.side_effect = Exception("Database error")
  92. # Act & Assert
  93. with pytest.raises(ApplicationException) as exc_info:
  94. await handler.handle(command)
  95. assert "Failed to create document" in str(exc_info.value)
  96. class TestUpdateDocumentHandler:
  97. """测试更新文档处理器"""
  98. @pytest.fixture
  99. def mock_repository(self):
  100. """创建 Mock 仓储"""
  101. repository = AsyncMock()
  102. return repository
  103. @pytest.fixture
  104. def handler(self, mock_repository):
  105. """创建处理器实例"""
  106. return UpdateDocumentHandler(mock_repository)
  107. @pytest.fixture
  108. def existing_document(self):
  109. """创建现有文档"""
  110. return Document(
  111. id=EntityId("doc_123"),
  112. content="Original content",
  113. embedding=Vector([1.0, 2.0, 3.0]),
  114. metadata={"source": "test", "version": "1.0"},
  115. created_at=Timestamp.now(),
  116. updated_at=Timestamp.now()
  117. )
  118. @pytest.mark.asyncio
  119. async def test_handle_updates_content_successfully(
  120. self, handler, mock_repository, existing_document
  121. ):
  122. """测试成功更新文档内容"""
  123. # Arrange
  124. mock_repository.find_by_id = AsyncMock(return_value=existing_document)
  125. mock_repository.save = AsyncMock()
  126. command = UpdateDocumentCommand(
  127. document_id="doc_123",
  128. content="Updated content"
  129. )
  130. # Act
  131. await handler.handle(command)
  132. # Assert
  133. mock_repository.find_by_id.assert_called_once()
  134. mock_repository.save.assert_called_once()
  135. # 验证文档内容已更新
  136. assert existing_document.content == "Updated content"
  137. # 验证嵌入向量已清除
  138. assert existing_document.embedding is None
  139. @pytest.mark.asyncio
  140. async def test_handle_updates_metadata_with_merge(
  141. self, handler, mock_repository, existing_document
  142. ):
  143. """测试合并更新元数据"""
  144. # Arrange
  145. mock_repository.find_by_id = AsyncMock(return_value=existing_document)
  146. mock_repository.save = AsyncMock()
  147. command = UpdateDocumentCommand(
  148. document_id="doc_123",
  149. metadata={"author": "John"},
  150. merge_metadata=True
  151. )
  152. # Act
  153. await handler.handle(command)
  154. # Assert
  155. # 验证元数据已合并
  156. assert existing_document.metadata["source"] == "test"
  157. assert existing_document.metadata["version"] == "1.0"
  158. assert existing_document.metadata["author"] == "John"
  159. @pytest.mark.asyncio
  160. async def test_handle_updates_metadata_without_merge(
  161. self, handler, mock_repository, existing_document
  162. ):
  163. """测试替换更新元数据"""
  164. # Arrange
  165. mock_repository.find_by_id = AsyncMock(return_value=existing_document)
  166. mock_repository.save = AsyncMock()
  167. command = UpdateDocumentCommand(
  168. document_id="doc_123",
  169. metadata={"author": "John"},
  170. merge_metadata=False
  171. )
  172. # Act
  173. await handler.handle(command)
  174. # Assert
  175. # 验证元数据已替换
  176. assert existing_document.metadata == {"author": "John"}
  177. assert "source" not in existing_document.metadata
  178. assert "version" not in existing_document.metadata
  179. @pytest.mark.asyncio
  180. async def test_handle_raises_not_found_when_document_does_not_exist(
  181. self, handler, mock_repository
  182. ):
  183. """测试文档不存在时抛出 ResourceNotFoundException"""
  184. # Arrange
  185. mock_repository.find_by_id = AsyncMock(return_value=None)
  186. command = UpdateDocumentCommand(
  187. document_id="nonexistent_doc",
  188. content="New content"
  189. )
  190. # Act & Assert
  191. with pytest.raises(ResourceNotFoundException) as exc_info:
  192. await handler.handle(command)
  193. assert "Document" in str(exc_info.value)
  194. assert "nonexistent_doc" in str(exc_info.value)
  195. class TestDeleteDocumentHandler:
  196. """测试删除文档处理器"""
  197. @pytest.fixture
  198. def mock_repository(self):
  199. """创建 Mock 仓储"""
  200. repository = AsyncMock()
  201. repository.delete = AsyncMock()
  202. return repository
  203. @pytest.fixture
  204. def handler(self, mock_repository):
  205. """创建处理器实例"""
  206. return DeleteDocumentHandler(mock_repository)
  207. @pytest.mark.asyncio
  208. async def test_handle_deletes_document_successfully(self, handler, mock_repository):
  209. """测试成功删除文档"""
  210. # Arrange
  211. command = DeleteDocumentCommand(document_id="doc_123")
  212. # Act
  213. await handler.handle(command)
  214. # Assert
  215. mock_repository.delete.assert_called_once()
  216. # 验证传递的文档 ID
  217. deleted_id = mock_repository.delete.call_args[0][0]
  218. assert str(deleted_id) == "doc_123"
  219. @pytest.mark.asyncio
  220. async def test_handle_raises_application_exception_on_repository_error(
  221. self, handler, mock_repository
  222. ):
  223. """测试仓储错误时抛出应用异常"""
  224. # Arrange
  225. command = DeleteDocumentCommand(document_id="doc_123")
  226. mock_repository.delete.side_effect = Exception("Database error")
  227. # Act & Assert
  228. with pytest.raises(ApplicationException) as exc_info:
  229. await handler.handle(command)
  230. assert "Failed to delete document" in str(exc_info.value)
  231. class TestSearchDocumentsHandler:
  232. """测试搜索文档处理器"""
  233. @pytest.fixture
  234. def mock_repository(self):
  235. """创建 Mock 仓储"""
  236. repository = AsyncMock()
  237. return repository
  238. @pytest.fixture
  239. def handler(self, mock_repository):
  240. """创建处理器实例"""
  241. return SearchDocumentsHandler(mock_repository)
  242. @pytest.fixture
  243. def search_results(self):
  244. """创建搜索结果"""
  245. doc1 = Document(
  246. id=EntityId("doc_1"),
  247. content="Machine learning document",
  248. embedding=Vector([1.0, 2.0, 3.0]),
  249. metadata={"category": "AI"},
  250. created_at=Timestamp.now(),
  251. updated_at=Timestamp.now()
  252. )
  253. doc2 = Document(
  254. id=EntityId("doc_2"),
  255. content="Deep learning document",
  256. embedding=Vector([2.0, 3.0, 4.0]),
  257. metadata={"category": "AI"},
  258. created_at=Timestamp.now(),
  259. updated_at=Timestamp.now()
  260. )
  261. return [
  262. SearchResult(document=doc1, score=0.95, rank=0),
  263. SearchResult(document=doc2, score=0.85, rank=1)
  264. ]
  265. @pytest.mark.asyncio
  266. async def test_handle_searches_documents_successfully(
  267. self, handler, mock_repository, search_results
  268. ):
  269. """测试成功搜索文档"""
  270. # Arrange
  271. mock_repository.search = AsyncMock(return_value=search_results)
  272. query = SearchDocumentsQuery(
  273. query_text="machine learning",
  274. top_k=10
  275. )
  276. # Act
  277. results = await handler.handle(query)
  278. # Assert
  279. assert len(results) == 2
  280. assert results[0].document.id == "doc_1"
  281. assert results[0].score == 0.95
  282. assert results[1].document.id == "doc_2"
  283. assert results[1].score == 0.85
  284. mock_repository.search.assert_called_once()
  285. @pytest.mark.asyncio
  286. async def test_handle_with_filters(
  287. self, handler, mock_repository, search_results
  288. ):
  289. """测试带过滤条件的搜索"""
  290. # Arrange
  291. mock_repository.search = AsyncMock(return_value=search_results)
  292. query = SearchDocumentsQuery(
  293. query_text="machine learning",
  294. top_k=10,
  295. filters={"category": "AI"}
  296. )
  297. # Act
  298. results = await handler.handle(query)
  299. # Assert
  300. assert len(results) == 2
  301. mock_repository.search.assert_called_once()
  302. # 验证传递的搜索查询
  303. search_query = mock_repository.search.call_args[0][0]
  304. assert isinstance(search_query, SearchQuery)
  305. assert search_query.filters == {"category": "AI"}
  306. class TestGetDocumentHandler:
  307. """测试获取文档处理器"""
  308. @pytest.fixture
  309. def mock_repository(self):
  310. """创建 Mock 仓储"""
  311. repository = AsyncMock()
  312. return repository
  313. @pytest.fixture
  314. def handler(self, mock_repository):
  315. """创建处理器实例"""
  316. return GetDocumentHandler(mock_repository)
  317. @pytest.fixture
  318. def existing_document(self):
  319. """创建现有文档"""
  320. return Document(
  321. id=EntityId("doc_123"),
  322. content="Test document",
  323. embedding=Vector([1.0, 2.0, 3.0]),
  324. metadata={"source": "test"},
  325. created_at=Timestamp.now(),
  326. updated_at=Timestamp.now()
  327. )
  328. @pytest.mark.asyncio
  329. async def test_handle_gets_document_successfully(
  330. self, handler, mock_repository, existing_document
  331. ):
  332. """测试成功获取文档"""
  333. # Arrange
  334. mock_repository.find_by_id = AsyncMock(return_value=existing_document)
  335. query = GetDocumentQuery(document_id="doc_123")
  336. # Act
  337. result = await handler.handle(query)
  338. # Assert
  339. assert result.id == "doc_123"
  340. assert result.content == "Test document"
  341. assert result.metadata == {"source": "test"}
  342. mock_repository.find_by_id.assert_called_once()
  343. @pytest.mark.asyncio
  344. async def test_handle_raises_not_found_when_document_does_not_exist(
  345. self, handler, mock_repository
  346. ):
  347. """测试文档不存在时抛出 ResourceNotFoundException"""
  348. # Arrange
  349. mock_repository.find_by_id = AsyncMock(return_value=None)
  350. query = GetDocumentQuery(document_id="nonexistent_doc")
  351. # Act & Assert
  352. with pytest.raises(ResourceNotFoundException) as exc_info:
  353. await handler.handle(query)
  354. assert "Document" in str(exc_info.value)
  355. assert "nonexistent_doc" in str(exc_info.value)
  356. @pytest.mark.asyncio
  357. async def test_handle_with_include_embedding(
  358. self, handler, mock_repository, existing_document
  359. ):
  360. """测试包含嵌入向量的获取"""
  361. # Arrange
  362. mock_repository.find_by_id = AsyncMock(return_value=existing_document)
  363. query = GetDocumentQuery(
  364. document_id="doc_123",
  365. include_embedding=True
  366. )
  367. # Act
  368. result = await handler.handle(query)
  369. # Assert
  370. assert result.embedding is not None
  371. assert result.embedding == [1.0, 2.0, 3.0]