"""Unit tests for BM25 hybrid search algorithm.""" from unittest.mock import AsyncMock, MagicMock import pytest from qdrant_client import models from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm @pytest.mark.unit def test_bm25_hybrid_initialization_default(): """Test BM25HybridSearchAlgorithm initializes with default RRF fusion.""" algo = BM25HybridSearchAlgorithm() assert algo.score_threshold == 0.0 assert algo.fusion == models.Fusion.RRF assert algo.fusion_name == "rrf" assert algo.name == "bm25_hybrid" @pytest.mark.unit def test_bm25_hybrid_initialization_with_rrf(): """Test BM25HybridSearchAlgorithm initializes with explicit RRF fusion.""" algo = BM25HybridSearchAlgorithm(score_threshold=0.5, fusion="rrf") assert algo.score_threshold == 0.5 assert algo.fusion == models.Fusion.RRF assert algo.fusion_name == "rrf" @pytest.mark.unit def test_bm25_hybrid_initialization_with_dbsf(): """Test BM25HybridSearchAlgorithm initializes with DBSF fusion.""" algo = BM25HybridSearchAlgorithm(score_threshold=0.7, fusion="dbsf") assert algo.score_threshold == 0.7 assert algo.fusion == models.Fusion.DBSF assert algo.fusion_name == "dbsf" @pytest.mark.unit def test_bm25_hybrid_invalid_fusion_raises_error(): """Test BM25HybridSearchAlgorithm raises ValueError for invalid fusion.""" with pytest.raises(ValueError) as exc_info: BM25HybridSearchAlgorithm(fusion="invalid") assert "Invalid fusion algorithm 'invalid'" in str(exc_info.value) assert "Must be 'rrf' or 'dbsf'" in str(exc_info.value) @pytest.mark.unit def test_bm25_hybrid_requires_vector_db(): """Test BM25HybridSearchAlgorithm reports it requires vector database.""" algo = BM25HybridSearchAlgorithm() assert algo.requires_vector_db is True @pytest.fixture def patched_search(monkeypatch): """Stub the embedding / BM25 / Qdrant deps of search() and return the embed_with_usage mock so tests can assert how often the query was embedded.""" embed = AsyncMock(return_value=([0.1, 0.2, 0.3], 7)) svc = MagicMock() svc.embed_with_usage = embed monkeypatch.setattr( "nextcloud_mcp_server.search.bm25_hybrid.get_embedding_service", lambda: svc ) bm25 = MagicMock() bm25.encode_async = AsyncMock(return_value={"indices": [1], "values": [0.5]}) monkeypatch.setattr( "nextcloud_mcp_server.search.bm25_hybrid.get_bm25_service", AsyncMock(return_value=bm25), ) qdrant = MagicMock() empty = MagicMock() empty.points = [] qdrant.query_points = AsyncMock(return_value=empty) monkeypatch.setattr( "nextcloud_mcp_server.search.bm25_hybrid.get_qdrant_client", AsyncMock(return_value=qdrant), ) settings = MagicMock() settings.get_collection_name.return_value = "test_collection" settings.get_embedding_provider_family.return_value = "mistral" monkeypatch.setattr( "nextcloud_mcp_server.search.bm25_hybrid.get_settings", lambda: settings ) monkeypatch.setattr( "nextcloud_mcp_server.search.bm25_hybrid.build_base_filter_conditions", lambda **kwargs: [], ) return embed @pytest.mark.unit async def test_query_embedded_and_metered_once_across_doc_types(patched_search): """nc_semantic_search calls search() once per doc_type on one instance with the same query; the dense embedding (and its billed token count) must be computed exactly once, not once per type.""" embed = patched_search algo = BM25HybridSearchAlgorithm() for dtype in ("note", "file", "deck_card"): await algo.search(query="hello", user_id="alice", doc_type=dtype) assert embed.await_count == 1 # embedded once, not 3× assert ( algo.query_token_count == 7 ) # single query's token count, not summed/overwritten assert algo.query_embedding == [0.1, 0.2, 0.3] @pytest.mark.unit async def test_different_query_invalidates_cache(patched_search): """A different query string re-embeds (and re-meters).""" embed = patched_search algo = BM25HybridSearchAlgorithm() await algo.search(query="hello", user_id="alice") await algo.search(query="world", user_id="alice") assert embed.await_count == 2