Hello world.
" +) + + @pytest.fixture -def html_parser(): - return HTMLParser() +def html_file(tmp_path): + path = tmp_path / "page.html" + path.write_text(HTML) + return path def test_html_init_parser(): @@ -21,23 +24,33 @@ def test_html_init_parser(): assert parser.parser_config_set -def test_html_parser_parse_file(): - parser = HTMLParser() - mock_doc = MagicMock() - mock_doc.page_content = "Extracted HTML content" - mock_doc.metadata = {"source": "test.html"} +def test_html_parser_extracts_text(html_file): + text = HTMLParser().parse_file(html_file) + assert "Heading" in text + assert "Hello world." in text - fake_lc = types.ModuleType("langchain_community") - fake_dl = types.ModuleType("langchain_community.document_loaders") - bshtml_mock = MagicMock(return_value=MagicMock(load=MagicMock(return_value=[mock_doc]))) - fake_dl.BSHTMLLoader = bshtml_mock - fake_lc.document_loaders = fake_dl +def test_html_parser_returns_plain_text(html_file): + """bulk.py stringifies parser output, so it must not be a repr. - with patch.dict(sys.modules, { - "langchain_community": fake_lc, - "langchain_community.document_loaders": fake_dl, - }): - result = parser.parse_file(Path("test.html")) - assert result == [mock_doc] - bshtml_mock.assert_called_once_with(Path("test.html")) + The previous langchain BSHTMLLoader returned a Document whose ``str()`` is + ``page_content='...' metadata={...}``, which leaked into indexed content. + """ + text = HTMLParser().parse_file(html_file) + assert isinstance(text, str) + assert "page_content" not in str(text) + assert "metadata=" not in str(text) + + +def test_html_parser_reports_title_metadata(html_file): + assert HTMLParser().get_file_metadata(html_file) == {"title": "My Page"} + + +def test_html_parser_metadata_without_title(tmp_path): + path = tmp_path / "no_title.html" + path.write_text("Just text.
") + assert HTMLParser().get_file_metadata(path) == {} + + +def test_html_parser_metadata_unreadable_file(tmp_path): + assert HTMLParser().get_file_metadata(tmp_path / "missing.html") == {} diff --git a/tests/parser/remote/test_reddit_loader.py b/tests/parser/remote/test_reddit_loader.py index 26199490..ddc6671c 100644 --- a/tests/parser/remote/test_reddit_loader.py +++ b/tests/parser/remote/test_reddit_loader.py @@ -1,83 +1,103 @@ import json -from unittest.mock import patch, MagicMock +import sys +import types +from unittest.mock import MagicMock, patch + import pytest from application.parser.remote.reddit_loader import RedditPostsLoaderRemote +def _post(post_id, title, body): + post = MagicMock() + post.id = post_id + post.title = title + post.selftext = body + post.subreddit_name_prefixed = "r/python" + post.score = 42 + post.url = f"https://reddit.com/{post_id}" + post.author = "someone" + return post + + +def _fake_praw(posts): + """Install a stub ``praw`` module whose listings yield ``posts``.""" + praw = types.ModuleType("praw") + reddit = MagicMock() + listing = MagicMock() + listing.new.return_value = posts + listing.hot.return_value = posts + reddit.subreddit.return_value = listing + reddit.redditor.return_value = MagicMock(submissions=listing) + praw.Reddit = MagicMock(return_value=reddit) + return praw, reddit, listing + + +BASE_PAYLOAD = { + "client_id": "cid", + "client_secret": "csecret", + "user_agent": "ua", + "search_queries": ["python"], +} + + +@pytest.mark.unit class TestRedditPostsLoaderRemote: def test_invalid_json_raises(self): - loader = RedditPostsLoaderRemote() - with pytest.raises(ValueError) as exc: - loader.load_data("not a json") - assert "Invalid JSON input" in str(exc.value) + with pytest.raises(ValueError, match="Invalid JSON input"): + RedditPostsLoaderRemote().load_data("not a json") def test_missing_required_fields_raises(self): - loader = RedditPostsLoaderRemote() - payload = json.dumps({"client_id": "id"}) - with pytest.raises(ValueError) as exc: - loader.load_data(payload) - assert "Missing required fields" in str(exc.value) + with pytest.raises(ValueError, match="Missing required fields") as exc: + RedditPostsLoaderRemote().load_data(json.dumps({"client_id": "id"})) assert "client_secret" in str(exc.value) - @patch("application.parser.remote.reddit_loader.RedditPostsLoader") - def test_constructs_loader_and_loads_with_defaults(self, MockRedditLoader): - loader = RedditPostsLoaderRemote() + def test_invalid_mode_raises(self): + payload = {**BASE_PAYLOAD, "mode": "comments"} + praw, _, _ = _fake_praw([]) + with patch.dict(sys.modules, {"praw": praw}): + with pytest.raises(ValueError, match="username.*subreddit|subreddit"): + RedditPostsLoaderRemote().load_data(json.dumps(payload)) - instance = MagicMock() - docs = [MagicMock(), MagicMock()] - instance.load.return_value = docs - MockRedditLoader.return_value = instance + def test_subreddit_mode_returns_documents(self): + praw, reddit, listing = _fake_praw([_post("p1", "First", "Body one")]) + with patch.dict(sys.modules, {"praw": praw}): + docs = RedditPostsLoaderRemote().load_data(json.dumps(BASE_PAYLOAD)) - payload = { - "client_id": "cid", - "client_secret": "csecret", - "user_agent": "ua", - "search_queries": ["r/langchain"], - } - - result = loader.load_data(json.dumps(payload)) - - MockRedditLoader.assert_called_once_with( - client_id="cid", - client_secret="csecret", - user_agent="ua", - categories=["new", "hot"], - mode="subreddit", - search_queries=["r/langchain"], - number_posts=10, + praw.Reddit.assert_called_once_with( + client_id="cid", client_secret="csecret", user_agent="ua" ) - instance.load.assert_called_once() - assert result == docs + reddit.subreddit.assert_called_with("python") + # Default categories are new + hot, so the single post arrives twice. + assert len(docs) == 2 + assert docs[0].text == "Body one" + assert docs[0].doc_id == "p1" + assert docs[0].extra_info["title"] == "First" + assert docs[0].extra_info["source"] == "https://reddit.com/p1" + assert docs[0].extra_info["post_category"] == "new" - @patch("application.parser.remote.reddit_loader.RedditPostsLoader") - def test_constructs_loader_and_loads_with_overrides(self, MockRedditLoader): - loader = RedditPostsLoaderRemote() + def test_documents_convert_to_vector_format(self): + """The remote base class calls to_vector_format on every result.""" + praw, _, _ = _fake_praw([_post("p1", "First", "Body one")]) + with patch.dict(sys.modules, {"praw": praw}): + docs = RedditPostsLoaderRemote().load_data(json.dumps(BASE_PAYLOAD)) + converted = docs[0].to_vector_format() + assert converted.page_content == "Body one" + assert converted.metadata["title"] == "First" - instance = MagicMock() - instance.load.return_value = [] - MockRedditLoader.return_value = instance + def test_username_mode_uses_redditor_submissions(self): + payload = {**BASE_PAYLOAD, "mode": "username", "categories": ["new"]} + praw, reddit, _ = _fake_praw([_post("p2", "Second", "Body two")]) + with patch.dict(sys.modules, {"praw": praw}): + docs = RedditPostsLoaderRemote().load_data(json.dumps(payload)) - payload = { - "client_id": "cid", - "client_secret": "csecret", - "user_agent": "ua", - "search_queries": ["python"], - "categories": ["hot"], - "mode": "comments", - "number_posts": 3, - } - - loader.load_data(json.dumps(payload)) - - MockRedditLoader.assert_called_once_with( - client_id="cid", - client_secret="csecret", - user_agent="ua", - categories=["hot"], - mode="comments", - search_queries=["python"], - number_posts=3, - ) - instance.load.assert_called_once() + reddit.redditor.assert_called_with("python") + assert len(docs) == 1 and docs[0].text == "Body two" + def test_number_posts_and_categories_are_forwarded(self): + payload = {**BASE_PAYLOAD, "categories": ["hot"], "number_posts": 3} + praw, _, listing = _fake_praw([]) + with patch.dict(sys.modules, {"praw": praw}): + RedditPostsLoaderRemote().load_data(json.dumps(payload)) + listing.hot.assert_called_once_with(limit=3) + listing.new.assert_not_called() diff --git a/tests/parser/remote/test_web_loader.py b/tests/parser/remote/test_web_loader.py index 51fa8841..758778a6 100644 --- a/tests/parser/remote/test_web_loader.py +++ b/tests/parser/remote/test_web_loader.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse from application.core.url_validation import SSRFError from application.parser.remote.web_loader import WebLoader, headers from application.parser.schema.base import Document -from langchain_core.documents import Document as LCDocument +from application.vectorstore.document_class import Document as LCDocument def _mock_validate_url(url): @@ -301,13 +301,13 @@ class TestWebLoaderIntegration: @patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url) @patch("application.parser.remote.web_loader.pinned_request") - def test_load_langchain_documents_method(self, mock_pinned_request, mock_validate, web_loader): + def test_load_vector_documents_method(self, mock_pinned_request, mock_validate, web_loader): mock_pinned_request.return_value = _fake_response( "Test web page content
" ) - result = web_loader.load_langchain_documents(inputs="https://example.com") + result = web_loader.load_vector_documents(inputs="https://example.com") assert len(result) == 1 assert isinstance(result[0], LCDocument) diff --git a/tests/parser/test_chunking_strategies.py b/tests/parser/test_chunking_strategies.py index 4fd53fe9..62973a1d 100644 --- a/tests/parser/test_chunking_strategies.py +++ b/tests/parser/test_chunking_strategies.py @@ -115,7 +115,7 @@ class TestParentChild: def test_parent_text_reaches_vectorstore_metadata(self): chunker = ParentChildChunker(max_tokens=80, min_tokens=20) out = chunker.chunk([Document(text="beta " * 150, doc_id="d")]) - lc = out[0].to_langchain_format() + lc = out[0].to_vector_format() # parent_text must survive the langchain conversion into metadata. assert "parent_text" in lc.metadata assert lc.metadata["parent_text"] diff --git a/tests/parser/test_schema.py b/tests/parser/test_schema.py index b6fdf7aa..9f492657 100644 --- a/tests/parser/test_schema.py +++ b/tests/parser/test_schema.py @@ -73,30 +73,30 @@ class TestDocumentBase: with pytest.raises(ValueError, match="text field not set"): Document(text=None) - def test_document_to_langchain_format(self): - """Cover line 27: Document.to_langchain_format converts correctly.""" + def test_document_to_vector_format(self): + """Cover line 27: Document.to_vector_format converts correctly.""" from application.parser.schema.base import Document doc = Document(text="hello world", extra_info={"source": "test"}) - lc_doc = doc.to_langchain_format() + lc_doc = doc.to_vector_format() assert lc_doc.page_content == "hello world" assert lc_doc.metadata == {"source": "test"} - def test_document_to_langchain_format_no_extra_info(self): - """Cover: to_langchain_format with no extra_info uses empty dict.""" + def test_document_to_vector_format_no_extra_info(self): + """Cover: to_vector_format with no extra_info uses empty dict.""" from application.parser.schema.base import Document doc = Document(text="hello") - lc_doc = doc.to_langchain_format() + lc_doc = doc.to_vector_format() assert lc_doc.metadata == {} - def test_document_from_langchain_format(self): - """Cover line 34: Document.from_langchain_format creates Document.""" + def test_document_from_vector_format(self): + """Cover line 34: Document.from_vector_format creates Document.""" from application.parser.schema.base import Document - from langchain_core.documents import Document as LCDocument + from application.vectorstore.document_class import Document as LCDocument lc_doc = LCDocument(page_content="test content", metadata={"key": "val"}) - doc = Document.from_langchain_format(lc_doc) + doc = Document.from_vector_format(lc_doc) assert doc.text == "test content" assert doc.extra_info == {"key": "val"} diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py index 31bddd14..d3aa3028 100644 --- a/tests/test_coverage_gaps.py +++ b/tests/test_coverage_gaps.py @@ -2031,70 +2031,54 @@ class TestInternalRoutes: # --------------------------------------------------------------------------- @pytest.mark.unit class TestFaissStore: - def test_faiss_init_load_from_storage(self): - mock_emb = MagicMock() - mock_storage = MagicMock() - mock_storage.file_exists.return_value = True - faiss_data = b"faiss_data" - pkl_data = b"pkl_data" - mock_storage.get_file.side_effect = [io.BytesIO(faiss_data), io.BytesIO(pkl_data)] + """Storage round-trip against a real FAISS index and real local storage.""" - mock_faiss_class = MagicMock() - mock_faiss_class.load_local.return_value = MagicMock() + class _Embeddings: + dimension = 3 + + def embed_documents(self, documents): + return [[0.1, 0.2, 0.3] for _ in documents] + + def embed_query(self, query): + return [0.1, 0.2, 0.3] + + class _Doc: + def __init__(self, page_content, metadata): + self.page_content = page_content + self.metadata = metadata + + def _make(self, storage, source_id="test", docs_init=None): + from application.vectorstore.faiss import FaissStore with patch( "application.vectorstore.base.BaseVectorStore._get_embeddings", - return_value=mock_emb, + return_value=self._Embeddings(), ), patch( "application.vectorstore.faiss.StorageCreator.get_storage", - return_value=mock_storage, - ), patch( - "application.vectorstore.faiss.FAISS", mock_faiss_class, - ), patch( - "application.vectorstore.faiss.settings" - ) as ms: + return_value=storage, + ), patch("application.vectorstore.faiss.settings") as ms: ms.EMBEDDINGS_NAME = "test" - from application.vectorstore.faiss import FaissStore + return FaissStore(source_id, "key", docs_init=docs_init) - store = FaissStore(source_id="test", embeddings_key="key") - assert store.docsearch is not None + def test_faiss_save_to_storage_writes_all_three_files(self, tmp_path): + from application.storage.local import LocalStorage - def test_faiss_save_to_storage(self): - mock_emb = MagicMock() - mock_storage = MagicMock() - mock_docsearch = MagicMock() + storage = LocalStorage(base_dir=str(tmp_path)) + store = self._make(storage, docs_init=[self._Doc("hello", {"source": "a"})]) - with patch( - "application.vectorstore.base.BaseVectorStore._get_embeddings", - return_value=mock_emb, - ), patch( - "application.vectorstore.faiss.StorageCreator.get_storage", - return_value=mock_storage, - ), patch( - "application.vectorstore.faiss.settings" - ) as ms: - ms.EMBEDDINGS_NAME = "test" - from application.vectorstore.faiss import FaissStore + assert store._save_to_storage() is True + for name in ("index.faiss", "index.json", "index.pkl"): + assert storage.file_exists(f"indexes/test/{name}"), name - store = FaissStore.__new__(FaissStore) - store.source_id = "test" - store.path = "indexes/test" - store.embeddings = mock_emb - store.storage = mock_storage - store.docsearch = mock_docsearch + def test_faiss_init_load_from_storage(self, tmp_path): + from application.storage.local import LocalStorage - def fake_save_local(temp_dir): - os.makedirs(temp_dir, exist_ok=True) - with open(os.path.join(temp_dir, "index.faiss"), "wb") as f: - f.write(b"faiss") - with open(os.path.join(temp_dir, "index.pkl"), "wb") as f: - f.write(b"pkl") + storage = LocalStorage(base_dir=str(tmp_path)) + self._make(storage, docs_init=[self._Doc("hello", {"source": "a"})]).save_local() - mock_docsearch.save_local.side_effect = fake_save_local - - result = store._save_to_storage() - assert result is True - assert mock_storage.save_file.call_count == 2 + reloaded = self._make(storage) + assert reloaded.index.ntotal == 1 + assert reloaded.get_chunks()[0]["text"] == "hello" # --------------------------------------------------------------------------- @@ -2102,58 +2086,52 @@ class TestFaissStore: # --------------------------------------------------------------------------- @pytest.mark.unit class TestQdrantStoreIndexCreation: - def test_init_index_already_exists_error(self): - mock_models = MagicMock() - mock_qdrant_langchain = MagicMock() + """A concurrent worker may create the collection or index first.""" - with patch( - "application.vectorstore.base.BaseVectorStore._get_embeddings" - ) as mock_get_emb, patch( - "application.vectorstore.qdrant.settings" - ) as mock_settings, patch.dict( - "sys.modules", - { - "qdrant_client": MagicMock(), - "qdrant_client.models": mock_models, - "langchain_community": MagicMock(), - "langchain_community.vectorstores": MagicMock(), - "langchain_community.vectorstores.qdrant": mock_qdrant_langchain, - }, + @staticmethod + def _settings(mock_settings): + mock_settings.EMBEDDINGS_NAME = "test" + mock_settings.QDRANT_COLLECTION_NAME = "coll" + mock_settings.QDRANT_LOCATION = ":memory:" + mock_settings.QDRANT_DISTANCE_FUNC = "Cosine" + mock_settings.QDRANT_PREFER_GRPC = False + mock_settings.QDRANT_GRPC_PORT = 6334 + for unset in ( + "QDRANT_URL", "QDRANT_HOST", "QDRANT_PORT", "QDRANT_HTTPS", + "QDRANT_API_KEY", "QDRANT_PREFIX", "QDRANT_TIMEOUT", "QDRANT_PATH", ): - mock_emb = Mock() - mock_emb.client = [None, Mock(word_embedding_dimension=768)] - mock_get_emb.return_value = mock_emb + setattr(mock_settings, unset, None) - mock_settings.EMBEDDINGS_NAME = "test" - mock_settings.QDRANT_COLLECTION_NAME = "coll" - mock_settings.QDRANT_LOCATION = ":memory:" - mock_settings.QDRANT_URL = None - mock_settings.QDRANT_PORT = 6333 - mock_settings.QDRANT_GRPC_PORT = 6334 - mock_settings.QDRANT_HTTPS = False - mock_settings.QDRANT_PREFER_GRPC = False - mock_settings.QDRANT_API_KEY = None - mock_settings.QDRANT_PREFIX = None - mock_settings.QDRANT_TIMEOUT = None - mock_settings.QDRANT_PATH = None - mock_settings.QDRANT_DISTANCE_FUNC = "Cosine" - - mock_docsearch = MagicMock() - mock_docsearch.client.get_collections.return_value.collections = [ - MagicMock(name="coll") - ] - # Index creation error with "already exists" - mock_docsearch.client.create_payload_index.side_effect = Exception( - "Index already exists" - ) - mock_qdrant_langchain.Qdrant.construct_instance.return_value = ( - mock_docsearch - ) - - from application.vectorstore.qdrant import QdrantStore + def test_init_swallows_already_exists_error(self): + from application.vectorstore.qdrant import QdrantStore + emb = Mock(dimension=8) + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=emb, + ), patch("application.vectorstore.qdrant.settings") as mock_settings, patch( + "qdrant_client.QdrantClient.create_payload_index", + side_effect=Exception("Index already exists"), + ): + self._settings(mock_settings) store = QdrantStore(source_id="test", embeddings_key="key") - assert store._docsearch is mock_docsearch + assert store._source_id == "test" + + def test_init_logs_other_setup_errors(self, caplog): + from application.vectorstore.qdrant import QdrantStore + + emb = Mock(dimension=8) + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=emb, + ), patch("application.vectorstore.qdrant.settings") as mock_settings, patch( + "qdrant_client.QdrantClient.create_payload_index", + side_effect=Exception("connection refused"), + ): + self._settings(mock_settings) + store = QdrantStore(source_id="test", embeddings_key="key") + assert store._source_id == "test" + assert "connection refused" in caplog.text # --------------------------------------------------------------------------- diff --git a/tests/test_remaining_coverage.py b/tests/test_remaining_coverage.py index 06e6ffe8..61ed844b 100644 --- a/tests/test_remaining_coverage.py +++ b/tests/test_remaining_coverage.py @@ -129,9 +129,9 @@ class TestToolABC: # application/parser/file/base.py (lines 18-19) # --------------------------------------------------------------------------- @pytest.mark.unit -class TestBaseReaderLoadLangchain: - def test_load_langchain_documents(self): - """Cover lines 18-19: BaseReader.load_langchain_documents.""" +class TestBaseReaderLoadVectorDocuments: + def test_load_vector_documents(self): + """Cover lines 18-19: BaseReader.load_vector_documents.""" from application.parser.file.base import BaseReader from application.parser.schema.base import Document @@ -143,7 +143,7 @@ class TestBaseReaderLoadLangchain: ] reader = ConcreteReader() - lc_docs = reader.load_langchain_documents() + lc_docs = reader.load_vector_documents() assert len(lc_docs) == 2 assert lc_docs[0].page_content == "hello" assert lc_docs[0].metadata == {"k": "v"} diff --git a/tests/vectorstore/test_faiss.py b/tests/vectorstore/test_faiss.py index b8840de9..d4228c47 100644 --- a/tests/vectorstore/test_faiss.py +++ b/tests/vectorstore/test_faiss.py @@ -1,628 +1,228 @@ -import io +"""FaissStore tests, run against a real FAISS index and real local storage. + +The store no longer wraps langchain, so these drive the actual index rather +than asserting that calls were forwarded to a mock. +""" + +import json from unittest.mock import Mock, patch import pytest - -@pytest.fixture -def mock_embeddings(): - emb = Mock() - emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) - emb.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) - emb.dimension = 3 - return emb +from application.storage.local import LocalStorage -@pytest.fixture -def mock_storage(): - storage = Mock() - storage.file_exists = Mock(return_value=True) - storage.get_file = Mock(return_value=io.BytesIO(b"fake data")) - storage.save_file = Mock() - return storage +class _FakeEmbeddings: + """Deterministic 3-dim embeddings: distinct texts get distinct directions.""" + dimension = 3 -@pytest.fixture -def mock_docsearch(): - ds = Mock() - ds.similarity_search = Mock(return_value=[]) - ds.add_texts = Mock(return_value=["id1"]) - ds.add_documents = Mock(return_value=["id1"]) - ds.save_local = Mock() - ds.delete = Mock() - ds.index = Mock() - ds.index.d = 3 - ds.docstore = Mock() - ds.docstore._dict = { - "doc1": Mock(page_content="text1", metadata={"source": "a"}), - "doc2": Mock(page_content="text2", metadata={"source": "b"}), + _VECTORS = { + "paris": [1.0, 0.0, 0.0], + "database": [0.0, 1.0, 0.0], + "celery": [0.0, 0.0, 1.0], } - return ds + + def _vector(self, text): + lowered = (text or "").lower() + for keyword, vector in self._VECTORS.items(): + if keyword in lowered: + return vector + return [0.5, 0.5, 0.5] + + def embed_query(self, query): + return self._vector(query) + + def embed_documents(self, documents): + return [self._vector(d) for d in documents] + + +class _SeedDoc: + def __init__(self, page_content, metadata): + self.page_content = page_content + self.metadata = metadata + + +@pytest.fixture +def storage(tmp_path): + return LocalStorage(base_dir=str(tmp_path)) + + +@pytest.fixture +def make_store(storage): + from application.vectorstore.faiss import FaissStore + + def _make(source_id="src", docs_init=None): + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=_FakeEmbeddings(), + ), patch( + "application.vectorstore.faiss.StorageCreator.get_storage", + return_value=storage, + ), patch("application.vectorstore.faiss.settings") as mock_settings: + mock_settings.EMBEDDINGS_NAME = "test_model" + return FaissStore(source_id, "key", docs_init=docs_init) + + return _make + + +@pytest.fixture +def populated(make_store): + store = make_store( + docs_init=[ + _SeedDoc("The capital of France is Paris.", {"source": "geo.txt"}), + _SeedDoc("Postgres is a relational database.", {"source": "db.txt"}), + _SeedDoc("Celery runs background tasks.", {"source": "queue.txt"}), + ] + ) + store.save_local() + return store @pytest.mark.unit -class TestFaissStoreInit: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_init_with_docs(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() +class TestFaissStore: + def test_build_from_documents(self, populated): + assert populated.index.ntotal == 3 + assert len(populated.get_chunks()) == 3 - from application.vectorstore.faiss import FaissStore + def test_search_ranks_by_similarity(self, populated): + hits = populated.search("Tell me about Paris", k=2) + assert "Paris" in str(hits[0]) + assert hits[0].metadata["source"] == "geo.txt" - store = FaissStore(source_id="test", embeddings_key="key", docs_init=[Mock()]) - mock_faiss.from_documents.assert_called_once() - assert store.docsearch is mock_ds + def test_search_with_scores_reports_l2(self, populated): + scored = populated.search_with_scores("Tell me about Paris", k=3) + assert populated.score_kind == "l2_distance" + # L2 is lower-is-better, so the ranking must be ascending. + assert [s for _, s in scored] == sorted(s for _, s in scored) + assert scored[0][1] == pytest.approx(0.0, abs=1e-4) - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_init_missing_index_files( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_storage = Mock() - mock_storage.file_exists.return_value = False - mock_storage_creator.get_storage.return_value = mock_storage + def test_search_drops_score_threshold(self, populated): + """FAISS has no threshold knob; the kwarg must be ignored, not raise.""" + assert populated.search("Paris", k=1, score_threshold=0.9) - from application.vectorstore.faiss import FaissStore + def test_search_on_empty_index_returns_empty(self, make_store): + store = make_store(docs_init=[_SeedDoc("only doc", {})]) + store.delete_index() + assert store.search("anything") == [] + def test_add_texts_appends(self, populated): + ids = populated.add_texts(["Redis caches things."], [{"source": "cache.txt"}]) + assert len(ids) == 1 + assert populated.index.ntotal == 4 + assert len(populated.get_chunks()) == 4 + + def test_add_texts_empty_is_noop(self, populated): + assert populated.add_texts([], []) == [] + assert populated.index.ntotal == 3 + + def test_add_and_delete_chunk_roundtrip(self, populated): + chunk_id = populated.add_chunk("Redis caches things.", {"source": "cache.txt"}) + assert len(populated.get_chunks()) == 4 + populated.delete_chunk(chunk_id) + assert len(populated.get_chunks()) == 3 + assert populated.index.ntotal == 3 + # The index must stay searchable after a removal renumbers the mapping. + assert populated.search("Paris", k=1) + + def test_delete_index_with_unknown_id_raises(self, populated): + with pytest.raises(ValueError, match="not found in index"): + populated.delete_index(["nope"]) + + def test_delete_index_without_ids_clears(self, populated): + populated.delete_index() + assert populated.get_chunks() == [] + assert populated.index.ntotal == 0 + + def test_get_chunks_shape(self, populated): + chunk = populated.get_chunks()[0] + assert set(chunk) == {"doc_id", "text", "metadata"} + + +@pytest.mark.unit +class TestFaissPersistence: + def test_save_writes_both_sidecars(self, populated, storage, tmp_path): + for name in ("index.faiss", "index.json", "index.pkl"): + assert storage.file_exists(f"indexes/src/{name}"), name + + def test_reload_prefers_json_sidecar(self, populated, make_store, storage): + reloaded = make_store() + assert len(reloaded.get_chunks()) == 3 + assert reloaded.index.ntotal == 3 + assert "Paris" in str(reloaded.search("Paris", k=1)[0]) + + def test_reload_falls_back_to_legacy_pickle(self, populated, make_store, storage, tmp_path): + (tmp_path / "indexes" / "src" / "index.json").unlink() + reloaded = make_store() + assert len(reloaded.get_chunks()) == 3 + assert "Paris" in str(reloaded.search("Paris", k=1)[0]) + + def test_missing_index_raises(self, make_store): with pytest.raises(Exception, match="Error loading FAISS index"): - FaissStore(source_id="test", embeddings_key="key") + make_store(source_id="never-written") + def test_save_local_writes_to_path(self, populated, tmp_path): + target = tmp_path / "exported" + populated.save_local(str(target)) + assert {p.name for p in target.iterdir()} == { + "index.faiss", "index.json", "index.pkl" + } -@pytest.mark.unit -class TestFaissStoreSearch: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_search_delegates_to_docsearch( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.similarity_search.return_value = ["doc1"] - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - result = store.search("query", k=5) - mock_ds.similarity_search.assert_called_once_with("query", k=5) - assert result == ["doc1"] - - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_search_ignores_score_threshold( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - # FAISS has no relevance-threshold knob; the per-source score_threshold - # must be safely dropped, not forwarded (which would crash langchain). - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_get_emb.return_value = Mock(dimension=3) - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.similarity_search.return_value = ["doc1"] - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - result = store.search("query", k=5, score_threshold=0.9) - # score_threshold is stripped before the forward. - mock_ds.similarity_search.assert_called_once_with("query", k=5) - assert result == ["doc1"] - - -@pytest.mark.unit -class TestFaissStoreAddTexts: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_add_texts_delegates( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.add_texts.return_value = ["id1", "id2"] - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - result = store.add_texts(["text1", "text2"]) - assert result == ["id1", "id2"] - - -@pytest.mark.unit -class TestFaissStoreGetChunks: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_get_chunks(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - - doc1 = Mock(page_content="text1", metadata={"source": "a"}) - doc2 = Mock(page_content="text2", metadata={"source": "b"}) - - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.docstore._dict = {"id1": doc1, "id2": doc2} - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - chunks = store.get_chunks() - - assert len(chunks) == 2 - texts = {c["text"] for c in chunks} - assert texts == {"text1", "text2"} - - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_get_chunks_empty(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.docstore._dict = {} - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - assert store.get_chunks() == [] - - -@pytest.mark.unit -class TestFaissStoreSaveLocal: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_save_local_with_path( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_faiss.from_documents.return_value = mock_ds - mock_storage = Mock() - mock_storage_creator.get_storage.return_value = mock_storage - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - - # Mock _save_to_storage to avoid file I/O - store._save_to_storage = Mock(return_value=True) - - with patch("os.makedirs"): - result = store.save_local(path="/tmp/test_save") - - mock_ds.save_local.assert_called_once_with("/tmp/test_save") - store._save_to_storage.assert_called_once() - assert result is True - - -@pytest.mark.unit -class TestFaissStoreDeleteIndex: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_delete_index_delegates( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - store.delete_index(["id1"]) - mock_ds.delete.assert_called_once_with(["id1"]) + def test_json_sidecar_is_readable_json(self, populated, tmp_path): + payload = json.loads((tmp_path / "indexes" / "src" / "index.json").read_text()) + assert payload["version"] == 1 + assert len(payload["documents"]) == 3 + assert len(payload["index_to_docstore_id"]) == 3 @pytest.mark.unit class TestFaissStoreAssertEmbeddingDimensions: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_dimension_mismatch_raises( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = ( - "huggingface_sentence-transformers/all-mpnet-base-v2" - ) - mock_emb = Mock(dimension=768) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=512) # Mismatched dimension - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() + def test_dimension_mismatch_raises(self, populated): + with patch("application.vectorstore.faiss.settings") as mock_settings: + mock_settings.EMBEDDINGS_NAME = ( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + with pytest.raises(ValueError, match="Embedding dimension mismatch"): + populated.assert_embedding_dimensions(Mock(dimension=768)) - from application.vectorstore.faiss import FaissStore + def test_missing_dimension_attr_raises(self, populated): + with patch("application.vectorstore.faiss.settings") as mock_settings: + mock_settings.EMBEDDINGS_NAME = ( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + embeddings = Mock() + del embeddings.dimension + with pytest.raises(AttributeError, match="'dimension' attribute not found"): + populated.assert_embedding_dimensions(embeddings) - with pytest.raises(ValueError, match="Embedding dimension mismatch"): - FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + def test_dimension_match_passes(self, populated): + with patch("application.vectorstore.faiss.settings") as mock_settings: + mock_settings.EMBEDDINGS_NAME = ( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + assert populated.assert_embedding_dimensions(Mock(dimension=3)) is None - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_missing_dimension_attr_raises( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = ( - "huggingface_sentence-transformers/all-mpnet-base-v2" - ) - mock_emb = Mock(spec=[]) # No dimension attribute - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=768) - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - with pytest.raises(AttributeError, match="dimension"): - FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - - -@pytest.mark.unit -class TestFaissStoreDeleteChunk: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_delete_chunk(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_faiss.from_documents.return_value = mock_ds - mock_storage = Mock() - mock_storage_creator.get_storage.return_value = mock_storage - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - store._save_to_storage = Mock(return_value=True) - - result = store.delete_chunk("chunk_id") - mock_ds.delete.assert_called_once_with(["chunk_id"]) - store._save_to_storage.assert_called_once() - assert result is True + def test_non_huggingface_skips_dimension_check(self, populated): + with patch("application.vectorstore.faiss.settings") as mock_settings: + mock_settings.EMBEDDINGS_NAME = "openai_text-embedding-ada-002" + assert populated.assert_embedding_dimensions(Mock(dimension=1536)) is None @pytest.mark.unit class TestGetVectorstore: - def test_with_path(self): - from application.vectorstore.faiss import get_vectorstore - - assert get_vectorstore("abc123") == "indexes/abc123" - - def test_without_path(self): + def test_empty_path_returns_base(self): from application.vectorstore.faiss import get_vectorstore assert get_vectorstore("") == "indexes" - assert get_vectorstore(None) == "indexes" - def test_with_nested_path(self): + def test_normal_path(self): from application.vectorstore.faiss import get_vectorstore - assert get_vectorstore("user/source123") == "indexes/user/source123" + assert get_vectorstore("abc") == "indexes/abc" - @pytest.mark.parametrize( - "malicious_path", - [ - "../outside", - "../../etc/passwd", - "nested/../../../outside", - "/tmp/evil", - "..\\outside", - "valid/../../escape", - ], - ) - def test_rejects_path_traversal(self, malicious_path): + @pytest.mark.parametrize("bad", ["../etc", "..\\etc", "a/../../b"]) + def test_traversal_rejected(self, bad): from application.vectorstore.faiss import get_vectorstore with pytest.raises(ValueError, match="Invalid source_id path"): - get_vectorstore(malicious_path) - - def test_allows_mongodb_style_ids(self): - from application.vectorstore.faiss import get_vectorstore - - assert get_vectorstore("65e8f6a8a7a96b1bdad4154f") == "indexes/65e8f6a8a7a96b1bdad4154f" - - -@pytest.mark.unit -class TestFaissStoreAddChunk: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_add_chunk_with_metadata( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.add_documents.return_value = ["new_id"] - mock_faiss.from_documents.return_value = mock_ds - mock_storage = Mock() - mock_storage_creator.get_storage.return_value = mock_storage - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - store._save_to_storage = Mock(return_value=True) - - doc_id = store.add_chunk("new text", metadata={"source": "test"}) - - assert doc_id == ["new_id"] - mock_ds.add_documents.assert_called_once() - store._save_to_storage.assert_called_once() - - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_add_chunk_default_metadata( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_ds.add_documents.return_value = ["new_id"] - mock_faiss.from_documents.return_value = mock_ds - mock_storage = Mock() - mock_storage_creator.get_storage.return_value = mock_storage - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - store._save_to_storage = Mock(return_value=True) - - doc_id = store.add_chunk("new text") - - assert doc_id == ["new_id"] - - -@pytest.mark.unit -class TestFaissStoreSaveLocalNoPath: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_save_local_without_path( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_emb = Mock(dimension=3) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=3) - mock_faiss.from_documents.return_value = mock_ds - mock_storage = Mock() - mock_storage_creator.get_storage.return_value = mock_storage - - from application.vectorstore.faiss import FaissStore - - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - store._save_to_storage = Mock(return_value=True) - - result = store.save_local() - - # Should NOT call docsearch.save_local with a path - mock_ds.save_local.assert_not_called() - store._save_to_storage.assert_called_once() - assert result is True - - -@pytest.mark.unit -class TestFaissStoreAssertEmbeddingDimensionsMatch: - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_dimension_match_passes( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = ( - "huggingface_sentence-transformers/all-mpnet-base-v2" - ) - mock_emb = Mock(dimension=768) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=768) # Matching dimension - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - # Should not raise - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - assert store is not None - - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_non_huggingface_skips_dimension_check( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - mock_settings.EMBEDDINGS_NAME = "openai_text-embedding-ada-002" - mock_emb = Mock(dimension=1536) - mock_get_emb.return_value = mock_emb - mock_ds = Mock() - mock_ds.index = Mock(d=999) # Mismatched but doesn't matter - mock_faiss.from_documents.return_value = mock_ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - # Should not raise since embedding name is not the huggingface one - store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - assert store is not None - - -@pytest.mark.unit -class TestFaissSearchWithScores: - @staticmethod - def _build(mock_faiss, mock_get_emb, mock_settings, mock_storage_creator, ds): - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_get_emb.return_value = Mock(dimension=3) - mock_faiss.from_documents.return_value = ds - mock_storage_creator.get_storage.return_value = Mock() - - from application.vectorstore.faiss import FaissStore - - return FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) - - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_reports_l2_distance_and_drops_threshold( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - doc = Mock(page_content="text1", metadata={"source": "a"}) - ds = Mock(index=Mock(d=3)) - ds.similarity_search_with_score.return_value = [(doc, 0.42)] - store = self._build( - mock_faiss, mock_get_emb, mock_settings, mock_storage_creator, ds - ) - - results = store.search_with_scores("query", k=5, score_threshold=0.9) - - # LangChain's FAISS ranks by L2 distance, NOT cosine similarity — the - # label must say so, and score_threshold must be dropped (FAISS has no - # such knob and would crash on it). - assert store.score_kind == "l2_distance" - ds.similarity_search_with_score.assert_called_once_with("query", k=5) - assert results == [(doc, 0.42)] - - @patch("application.vectorstore.faiss.StorageCreator") - @patch("application.vectorstore.faiss.FAISS") - @patch.object( - __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, - "_get_embeddings", - ) - @patch("application.vectorstore.faiss.settings") - def test_does_not_mutate_docstore_metadata( - self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator - ): - # similarity_search_with_score hands back the LIVE docstore Documents; - # writing a score into their metadata would pollute the in-memory index - # and could be persisted back to storage by a later add_chunk. - doc = Mock(page_content="text1", metadata={"source": "a"}) - ds = Mock(index=Mock(d=3)) - ds.similarity_search_with_score.return_value = [(doc, 0.42)] - store = self._build( - mock_faiss, mock_get_emb, mock_settings, mock_storage_creator, ds - ) - - store.search_with_scores("query", k=1) - - assert doc.metadata == {"source": "a"} + get_vectorstore(bad) diff --git a/tests/vectorstore/test_faiss_docstore.py b/tests/vectorstore/test_faiss_docstore.py new file mode 100644 index 00000000..1723764f --- /dev/null +++ b/tests/vectorstore/test_faiss_docstore.py @@ -0,0 +1,191 @@ +"""Tests for the FAISS sidecar formats and the langchain compatibility layer. + +Two properties matter here and neither can regress quietly: + +1. Indexes written by langchain must stay readable forever — ``POST + /api/upload_index`` still accepts them, so this is not a one-off migration. +2. The reader must refuse arbitrary pickled globals. The previous + ``pickle.load`` ran whatever an uploaded ``index.pkl`` told it to. +""" + +import io +import pickle +import pickletools + +import pytest + +from application.vectorstore.faiss_docstore import ( + CompatUnpickler, + LegacyDocstore, + LegacyDocument, + dump_json_sidecar, + dump_pickle_sidecar, + load_json_sidecar, + load_pickle_sidecar, +) + +DOCUMENTS = { + "id-1": {"page_content": "The capital of France is Paris.", "metadata": {"source": "geo.txt"}}, + "id-2": {"page_content": "Postgres is a database.", "metadata": {"source": "db.txt"}}, +} +MAPPING = {0: "id-1", 1: "id-2"} + + +def _emitted_symbols(data: bytes): + """Return the (module, name) pairs a pickle payload references.""" + strings, symbols = [], [] + for op, arg, _ in pickletools.genops(io.BytesIO(data)): + if op.name in ("SHORT_BINUNICODE", "BINUNICODE"): + strings.append(arg) + if op.name == "STACK_GLOBAL": + symbols.append((strings[-2], strings[-1])) + return set(symbols) + + +@pytest.mark.unit +class TestPickleSidecar: + def test_round_trip(self): + documents, mapping = load_pickle_sidecar(dump_pickle_sidecar(DOCUMENTS, MAPPING)) + assert documents == DOCUMENTS + assert mapping == MAPPING + + def test_written_pickle_references_langchain_paths(self): + """An older DocsGPT still has langchain, so our output must name it.""" + assert _emitted_symbols(dump_pickle_sidecar(DOCUMENTS, MAPPING)) == { + ("langchain_core.documents.base", "Document"), + ("langchain_community.docstore.in_memory", "InMemoryDocstore"), + } + + def test_dump_does_not_leak_modules(self): + import sys + + dump_pickle_sidecar(DOCUMENTS, MAPPING) + assert not [m for m in sys.modules if m.startswith("langchain")] + assert LegacyDocument.__module__.startswith("application.") + assert LegacyDocstore.__module__.startswith("application.") + + def test_reads_pydantic_state_shape(self): + """langchain's Document is a pydantic model; that is the state we get.""" + doc = LegacyDocument.__new__(LegacyDocument) + doc.__setstate__( + { + "__dict__": { + "id": None, + "metadata": {"a": 1}, + "page_content": "hello", + "type": "Document", + }, + "__pydantic_extra__": None, + "__pydantic_fields_set__": {"page_content"}, + "__pydantic_private__": None, + } + ) + assert doc.page_content == "hello" + assert doc.metadata == {"a": 1} + + def test_missing_metadata_becomes_empty_dict(self): + doc = LegacyDocument.__new__(LegacyDocument) + doc.__setstate__({"__dict__": {"page_content": "x", "metadata": None}}) + assert doc.metadata == {} + + @pytest.mark.parametrize( + "module,name", + [ + ("langchain_core.documents.base", "Document"), + ("langchain_core.documents", "Document"), + ("langchain.schema.document", "Document"), + ("langchain.docstore.document", "Document"), + ("langchain_community.docstore.in_memory", "InMemoryDocstore"), + ("langchain.docstore.in_memory", "InMemoryDocstore"), + ], + ) + def test_historical_module_paths_resolve(self, module, name): + """Older langchain releases wrote these classes at different paths.""" + assert CompatUnpickler(io.BytesIO(b"")).find_class(module, name) is not None + + +@pytest.mark.unit +class TestUnpicklerRefusesArbitraryCode: + def test_refuses_os_system(self): + class Exploit: + def __reduce__(self): + import os + + return (os.system, ("echo pwned",)) + + payload = pickle.dumps(Exploit()) + with pytest.raises(pickle.UnpicklingError, match="Refusing to load"): + CompatUnpickler(io.BytesIO(payload)).load() + + def test_refuses_subprocess(self): + class Exploit: + def __reduce__(self): + import subprocess + + return (subprocess.Popen, (["true"],)) + + with pytest.raises(pickle.UnpicklingError, match="Refusing to load"): + CompatUnpickler(io.BytesIO(pickle.dumps(Exploit()))).load() + + def test_refuses_unrelated_builtin(self): + with pytest.raises(pickle.UnpicklingError, match="Refusing to load"): + CompatUnpickler(io.BytesIO(b"")).find_class("builtins", "eval") + + def test_plain_unpickler_would_have_resolved_it(self): + """Confirms the guard is load-bearing, not decorative. + + A stock Unpickler hands back ``os.system`` for the same input the + CompatUnpickler rejects — that resolution is what made the previous + ``pickle.load`` on uploaded indexes an arbitrary-code-execution path. + """ + import os + + assert pickle.Unpickler(io.BytesIO(b"")).find_class("os", "system") is os.system + with pytest.raises(pickle.UnpicklingError, match="Refusing to load"): + CompatUnpickler(io.BytesIO(b"")).find_class("os", "system") + + +@pytest.mark.unit +class TestJsonSidecar: + def test_round_trip(self): + documents, mapping = load_json_sidecar(dump_json_sidecar(DOCUMENTS, MAPPING)) + assert documents == DOCUMENTS + assert mapping == MAPPING + + def test_contains_no_pickle(self): + assert b"langchain" not in dump_json_sidecar(DOCUMENTS, MAPPING) + + def test_row_keys_come_back_as_ints(self): + _, mapping = load_json_sidecar(dump_json_sidecar(DOCUMENTS, MAPPING)) + assert all(isinstance(row, int) for row in mapping) + + def test_unicode_survives(self): + documents = {"id": {"page_content": "café — naïve 日本語", "metadata": {}}} + restored, _ = load_json_sidecar(dump_json_sidecar(documents, {0: "id"})) + assert restored["id"]["page_content"] == "café — naïve 日本語" + + def test_empty_index(self): + documents, mapping = load_json_sidecar(dump_json_sidecar({}, {})) + assert documents == {} and mapping == {} + + +@pytest.mark.unit +class TestRealLegacyFixture: + """The index.pkl checked into the repo was written by langchain in 2025.""" + + def test_reads_committed_legacy_index(self): + with open("application/index.pkl", "rb") as f: + documents, mapping = load_pickle_sidecar(f.read()) + assert len(documents) == 3 + assert len(mapping) == 3 + assert all(d["page_content"] for d in documents.values()) + assert all(d["metadata"].get("title") for d in documents.values()) + + def test_legacy_survives_conversion_to_json(self): + with open("application/index.pkl", "rb") as f: + documents, mapping = load_pickle_sidecar(f.read()) + restored, restored_mapping = load_json_sidecar( + dump_json_sidecar(documents, mapping) + ) + assert restored == documents + assert restored_mapping == mapping diff --git a/tests/vectorstore/test_milvus.py b/tests/vectorstore/test_milvus.py index e993ea8b..67b6f4c6 100644 --- a/tests/vectorstore/test_milvus.py +++ b/tests/vectorstore/test_milvus.py @@ -1,95 +1,173 @@ -from unittest.mock import MagicMock, Mock, patch -from uuid import UUID +"""MilvusStore tests, run against a real Milvus Lite database. + +pymilvus is a first-party dependency now that the store no longer goes through +langchain, so these drive a real local Milvus (a file under ``tmp_path``) +rather than asserting against mocks. +""" + +import os +from unittest.mock import patch import pytest +pytest.importorskip("milvus_lite", reason="Milvus Lite is not available on this platform") + + +class _FakeEmbeddings: + """Deterministic 3-dim embeddings: distinct texts get distinct directions.""" + + dimension = 3 + + _VECTORS = { + "paris": [1.0, 0.0, 0.0], + "database": [0.0, 1.0, 0.0], + "celery": [0.0, 0.0, 1.0], + } + + def _vector(self, text): + lowered = (text or "").lower() + for keyword, vector in self._VECTORS.items(): + if keyword in lowered: + return vector + return [0.577, 0.577, 0.577] + + def embed_query(self, query): + return self._vector(query) + + def embed_documents(self, documents): + return [self._vector(d) for d in documents] + + +@pytest.fixture +def store(tmp_path): + from application.vectorstore.milvus import MilvusStore -def _make_milvus_store(source_id="test-source"): - """Helper to create a MilvusStore with mocked deps.""" with patch( - "application.vectorstore.base.BaseVectorStore._get_embeddings" - ) as mock_get_emb, patch( - "application.vectorstore.milvus.settings" - ) as mock_settings, patch.dict( - "sys.modules", - { - "langchain_milvus": MagicMock(), - }, - ): - mock_emb = Mock() - mock_get_emb.return_value = mock_emb + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=_FakeEmbeddings(), + ), patch("application.vectorstore.milvus.settings") as mock_settings: mock_settings.EMBEDDINGS_NAME = "test_model" - mock_settings.MILVUS_URI = "http://localhost:19530" - mock_settings.MILVUS_TOKEN = "token" mock_settings.MILVUS_COLLECTION_NAME = "test_collection" + mock_settings.MILVUS_URI = str(tmp_path / "milvus.db") + mock_settings.MILVUS_TOKEN = "" + yield MilvusStore(source_id="src-A", embeddings_key="k") - from langchain_milvus import Milvus - mock_docsearch = MagicMock() - Milvus.return_value = mock_docsearch +@pytest.fixture +def populated(store): + store.add_texts( + ["The capital of France is Paris.", + "Postgres is a relational database.", + "Celery runs background tasks."], + [{"source": "geo.txt"}, {"source": "db.txt"}, {"source": "queue.txt"}], + ) + return store + +@pytest.mark.unit +class TestMilvusStore: + def test_source_id_stored(self, store): + assert store._source_id == "src-A" + + def test_filter_expression_scopes_to_source(self, store): + assert store._filter == 'source_id == "src-A"' + + def test_add_texts_returns_one_id_per_text(self, store): + ids = store.add_texts(["a", "b"], [{}, {}]) + assert len(ids) == 2 and len(set(ids)) == 2 + + def test_add_texts_empty_is_noop(self, store): + assert store.add_texts([], []) == [] + + def test_search_ranks_by_similarity(self, populated): + hits = populated.search("Tell me about Paris", k=2) + assert "Paris" in str(hits[0]) + assert hits[0].metadata["source"] == "geo.txt" + + def test_search_with_scores_reports_cosine(self, populated): + scored = populated.search_with_scores("Tell me about Paris", k=3) + assert populated.score_kind == "cosine_similarity" + assert scored[0][1] == pytest.approx(1.0, abs=1e-3) + + def test_search_honours_score_threshold(self, populated): + assert populated.search_with_scores("Paris", k=3, score_threshold=0.99) + assert not populated.search_with_scores("Paris", k=3, score_threshold=1.01) + + def test_get_chunks_returns_all(self, populated): + chunks = populated.get_chunks() + assert len(chunks) == 3 + assert {c["metadata"]["source"] for c in chunks} == { + "geo.txt", "db.txt", "queue.txt" + } + + def test_add_texts_stamps_source_id(self, populated): + assert all(c["metadata"]["source_id"] == "src-A" for c in populated.get_chunks()) + + def test_add_and_delete_chunk(self, populated): + chunk_id = populated.add_chunk("Redis caches things.", {"source": "cache.txt"}) + assert len(populated.get_chunks()) == 4 + assert populated.delete_chunk(chunk_id) is True + assert len(populated.get_chunks()) == 3 + + def test_delete_chunks_by_source_path(self, populated): + assert populated.delete_chunks_by_source_path("db.txt") == 1 + assert len(populated.get_chunks()) == 2 + + def test_delete_index_removes_only_this_source(self, populated): from application.vectorstore.milvus import MilvusStore - store = MilvusStore(source_id=source_id, embeddings_key="key") - store._docsearch = mock_docsearch + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=_FakeEmbeddings(), + ), patch("application.vectorstore.milvus.settings") as mock_settings: + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.MILVUS_COLLECTION_NAME = "test_collection" + mock_settings.MILVUS_URI = ":memory:" + mock_settings.MILVUS_TOKEN = "" + other = MilvusStore.__new__(MilvusStore) + # Share the open Milvus Lite handle: one file, two logical sources. + other._client = populated._client + other._collection = populated._collection + other._source_id = "src-B" + other._embeddings = _FakeEmbeddings() + other.add_texts(["Another source entirely."], [{"source": "other.txt"}]) - return store, mock_docsearch + populated.delete_index() + assert populated.get_chunks() == [] + assert len(other.get_chunks()) == 1 -@pytest.mark.unit -class TestMilvusStoreInit: - def test_source_id_stored(self): - store, _ = _make_milvus_store(source_id="src1") - assert store._source_id == "src1" + def test_get_chunks_returns_empty_when_client_raises(self, populated): + with patch.object( + populated._client, "query", side_effect=RuntimeError("milvus down") + ): + assert populated.get_chunks() == [] - -@pytest.mark.unit -class TestMilvusStoreSearch: - def test_search(self): - store, mock_ds = _make_milvus_store(source_id="src1") - mock_ds.similarity_search.return_value = ["doc1", "doc2"] - - results = store.search("query", k=3) - - mock_ds.similarity_search.assert_called_once() - call_kwargs = mock_ds.similarity_search.call_args - assert call_kwargs[1]["query"] == "query" - assert call_kwargs[1]["k"] == 3 - assert call_kwargs[1]["expr"] == "source_id == 'src1'" - assert results == ["doc1", "doc2"] - - -@pytest.mark.unit -class TestMilvusStoreAddTexts: - def test_add_texts(self): - store, mock_ds = _make_milvus_store() - mock_ds.add_texts.return_value = ["id1", "id2"] - - result = store.add_texts( - ["text1", "text2"], metadatas=[{"a": 1}, {"b": 2}] - ) - - mock_ds.add_texts.assert_called_once() - call_kwargs = mock_ds.add_texts.call_args - assert call_kwargs[1]["texts"] == ["text1", "text2"] - # ids should be UUIDs - ids = call_kwargs[1]["ids"] - assert len(ids) == 2 - for uid in ids: - UUID(uid) # Validates it's a valid UUID - - assert result == ["id1", "id2"] - - -@pytest.mark.unit -class TestMilvusStoreSaveLocal: - def test_save_local_is_noop(self): - store, _ = _make_milvus_store() + def test_save_local_is_noop(self, store): assert store.save_local() is None @pytest.mark.unit -class TestMilvusStoreDeleteIndex: - def test_delete_index_is_noop(self): - store, _ = _make_milvus_store() - assert store.delete_index() is None +class TestMilvusUriEnvGuard: + """pymilvus reads MILVUS_URI at import and rejects non-http values. + + DocsGPT's setting of the same name defaults to a Milvus Lite file path, so + the import must be shielded from it and the value restored afterwards. + """ + + def test_env_var_hidden_during_import_and_restored(self): + from application.vectorstore.milvus import _without_milvus_uri_env + + with patch.dict(os.environ, {"MILVUS_URI": "./milvus_local.db"}): + with _without_milvus_uri_env(): + assert "MILVUS_URI" not in os.environ + assert os.environ["MILVUS_URI"] == "./milvus_local.db" + + def test_absent_env_var_stays_absent(self): + from application.vectorstore.milvus import _without_milvus_uri_env + + env = {k: v for k, v in os.environ.items() if k != "MILVUS_URI"} + with patch.dict(os.environ, env, clear=True): + with _without_milvus_uri_env(): + assert "MILVUS_URI" not in os.environ + assert "MILVUS_URI" not in os.environ diff --git a/tests/vectorstore/test_qdrant.py b/tests/vectorstore/test_qdrant.py index 2deb6a69..16c40f9e 100644 --- a/tests/vectorstore/test_qdrant.py +++ b/tests/vectorstore/test_qdrant.py @@ -1,230 +1,183 @@ -from unittest.mock import MagicMock, Mock, patch +"""QdrantStore tests, run against a real in-process Qdrant. + +qdrant-client is a first-party dependency now that the store no longer goes +through langchain, so these exercise the real client in ``:memory:`` mode +rather than asserting against mocks. +""" + +from unittest.mock import patch import pytest -def _make_qdrant_store(source_id="test-source"): - """Helper to create a QdrantStore with all external deps mocked.""" - mock_models = MagicMock() - mock_qdrant_langchain = MagicMock() +class _FakeEmbeddings: + """Deterministic 3-dim embeddings: distinct texts get distinct directions.""" + + dimension = 3 + + _VECTORS = { + "paris": [1.0, 0.0, 0.0], + "database": [0.0, 1.0, 0.0], + "celery": [0.0, 0.0, 1.0], + } + + def _vector(self, text): + lowered = (text or "").lower() + for keyword, vector in self._VECTORS.items(): + if keyword in lowered: + return vector + return [0.577, 0.577, 0.577] + + def embed_query(self, query): + return self._vector(query) + + def embed_documents(self, documents): + return [self._vector(d) for d in documents] + + +def _settings(mock_settings, collection="test_collection"): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.QDRANT_COLLECTION_NAME = collection + mock_settings.QDRANT_LOCATION = ":memory:" + mock_settings.QDRANT_DISTANCE_FUNC = "Cosine" + mock_settings.QDRANT_PREFER_GRPC = False + mock_settings.QDRANT_GRPC_PORT = 6334 + for unset in ( + "QDRANT_URL", "QDRANT_HOST", "QDRANT_PORT", "QDRANT_HTTPS", + "QDRANT_API_KEY", "QDRANT_PREFIX", "QDRANT_TIMEOUT", "QDRANT_PATH", + ): + setattr(mock_settings, unset, None) + + +@pytest.fixture +def store(): + from application.vectorstore.qdrant import QdrantStore with patch( - "application.vectorstore.base.BaseVectorStore._get_embeddings" - ) as mock_get_emb, patch( - "application.vectorstore.qdrant.settings" - ) as mock_settings, patch.dict( - "sys.modules", - { - "qdrant_client": MagicMock(), - "qdrant_client.models": mock_models, - "langchain_community": MagicMock(), - "langchain_community.vectorstores": MagicMock(), - "langchain_community.vectorstores.qdrant": mock_qdrant_langchain, - }, - ): - mock_emb = Mock() - mock_emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) - mock_emb.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) - mock_emb.client = [None, Mock(word_embedding_dimension=768)] - mock_get_emb.return_value = mock_emb + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=_FakeEmbeddings(), + ), patch("application.vectorstore.qdrant.settings") as mock_settings: + _settings(mock_settings) + yield QdrantStore(source_id="src-A", embeddings_key="k") - mock_settings.EMBEDDINGS_NAME = "test_model" - mock_settings.QDRANT_COLLECTION_NAME = "test_collection" - mock_settings.QDRANT_LOCATION = ":memory:" - mock_settings.QDRANT_URL = None - mock_settings.QDRANT_PORT = 6333 - mock_settings.QDRANT_GRPC_PORT = 6334 - mock_settings.QDRANT_HTTPS = False - mock_settings.QDRANT_PREFER_GRPC = False - mock_settings.QDRANT_API_KEY = None - mock_settings.QDRANT_PREFIX = None - mock_settings.QDRANT_TIMEOUT = None - mock_settings.QDRANT_PATH = None - mock_settings.QDRANT_DISTANCE_FUNC = "Cosine" - mock_docsearch = MagicMock() - mock_collections = MagicMock() - mock_collections.collections = [MagicMock(name="test_collection")] - mock_docsearch.client.get_collections.return_value = mock_collections - mock_qdrant_langchain.Qdrant.construct_instance.return_value = mock_docsearch +@pytest.fixture +def populated(store): + store.add_texts( + ["The capital of France is Paris.", + "Postgres is a relational database.", + "Celery runs background tasks."], + [{"source": "geo.txt"}, {"source": "db.txt"}, {"source": "queue.txt"}], + ) + return store + +@pytest.mark.unit +class TestQdrantStore: + def test_source_id_strips_index_prefix(self, store): + assert store._source_id == "src-A" + + def test_add_texts_returns_one_id_per_text(self, store): + ids = store.add_texts(["a", "b"], [{}, {}]) + assert len(ids) == 2 and len(set(ids)) == 2 + + def test_add_texts_empty_is_noop(self, store): + assert store.add_texts([], []) == [] + + def test_search_ranks_by_similarity(self, populated): + hits = populated.search("Tell me about Paris", k=2) + assert "Paris" in str(hits[0]) + assert hits[0].metadata["source"] == "geo.txt" + + def test_search_with_scores_reports_cosine(self, populated): + scored = populated.search_with_scores("Tell me about Paris", k=3) + assert populated.score_kind == "cosine_similarity" + assert scored[0][1] == pytest.approx(1.0, abs=1e-3) + # Scores must come back in descending rank order. + assert [s for _, s in scored] == sorted( + (s for _, s in scored), reverse=True + ) + + def test_search_honours_score_threshold(self, populated): + assert populated.search_with_scores("Paris", k=3, score_threshold=0.99) + assert not populated.search_with_scores("Paris", k=3, score_threshold=1.01) + + def test_add_texts_stamps_source_id(self, populated): + assert all(c["metadata"]["source_id"] == "src-A" for c in populated.get_chunks()) + + def test_get_chunks_returns_all(self, populated): + chunks = populated.get_chunks() + assert len(chunks) == 3 + assert {c["metadata"]["source"] for c in chunks} == { + "geo.txt", "db.txt", "queue.txt" + } + + def test_add_and_delete_chunk(self, populated): + chunk_id = populated.add_chunk("Redis caches things.", {"source": "cache.txt"}) + assert len(populated.get_chunks()) == 4 + assert populated.delete_chunk(chunk_id) is True + assert len(populated.get_chunks()) == 3 + + def test_delete_chunk_returns_false_when_client_raises(self, populated): + with patch.object( + populated._client, "delete", side_effect=RuntimeError("qdrant down") + ): + assert populated.delete_chunk("some-id") is False + + def test_get_chunks_returns_empty_when_client_raises(self, populated): + with patch.object( + populated._client, "scroll", side_effect=RuntimeError("qdrant down") + ): + assert populated.get_chunks() == [] + + def test_delete_chunks_by_source_path(self, populated): + assert populated.delete_chunks_by_source_path("db.txt") == 1 + assert len(populated.get_chunks()) == 2 + + def test_delete_index_removes_only_this_source(self, populated): from application.vectorstore.qdrant import QdrantStore - store = QdrantStore(source_id=source_id, embeddings_key="key") + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings", + return_value=_FakeEmbeddings(), + ), patch("application.vectorstore.qdrant.settings") as mock_settings: + _settings(mock_settings) + other = QdrantStore(source_id="src-B", embeddings_key="k") + # Share the in-memory backend so both sources live in one collection. + other._client = populated._client + other.add_texts(["Another source entirely."], [{"source": "other.txt"}]) - return store, mock_docsearch, mock_settings + populated.delete_index() + assert populated.get_chunks() == [] + assert len(other.get_chunks()) == 1 -@pytest.mark.unit -class TestQdrantStoreInit: - def test_source_id_cleaned(self): - store, _, _ = _make_qdrant_store(source_id="application/indexes/abc123/") - assert store._source_id == "abc123" - - def test_filter_constructed(self): - store, _, _ = _make_qdrant_store(source_id="src1") - assert store._filter is not None - - -@pytest.mark.unit -class TestQdrantStoreSearch: - def test_search_delegates(self): - store, mock_ds, _ = _make_qdrant_store() - mock_ds.similarity_search.return_value = ["result1"] - - results = store.search("query", k=5) - - mock_ds.similarity_search.assert_called_once() - assert results == ["result1"] - - -@pytest.mark.unit -class TestQdrantStoreAddTexts: - def test_add_texts_delegates(self): - store, mock_ds, _ = _make_qdrant_store() - mock_ds.add_texts.return_value = ["id1"] - - result = store.add_texts(["text1"], metadatas=[{"a": 1}]) - mock_ds.add_texts.assert_called_once_with(["text1"], metadatas=[{"a": 1}]) - assert result == ["id1"] - - -@pytest.mark.unit -class TestQdrantStoreSaveLocal: - def test_save_local_is_noop(self): - store, _, _ = _make_qdrant_store() + def test_save_local_is_noop(self, store): assert store.save_local() is None @pytest.mark.unit -class TestQdrantStoreDeleteIndex: - def test_delete_index(self): - store, mock_ds, _ = _make_qdrant_store() +class TestQdrantClientKwargs: + def test_unset_settings_are_omitted(self): + from application.vectorstore.qdrant import QdrantStore - with patch("application.vectorstore.qdrant.settings") as ms: - ms.QDRANT_COLLECTION_NAME = "test_collection" - store.delete_index() + with patch("application.vectorstore.qdrant.settings") as mock_settings: + _settings(mock_settings) + kwargs = QdrantStore._client_kwargs() + # location/url/path are mutually exclusive in qdrant-client, so only + # the configured one may be forwarded. + assert kwargs["location"] == ":memory:" + assert "url" not in kwargs and "path" not in kwargs and "host" not in kwargs - mock_ds.client.delete.assert_called_once_with( - collection_name="test_collection", - points_selector=store._filter, - ) + def test_configured_settings_are_forwarded(self): + from application.vectorstore.qdrant import QdrantStore - -@pytest.mark.unit -class TestQdrantStoreGetChunks: - def test_get_chunks(self): - store, mock_ds, _ = _make_qdrant_store() - - record1 = MagicMock() - record1.id = "id1" - record1.payload = { - "page_content": "text1", - "metadata": {"source": "test"}, - } - record2 = MagicMock() - record2.id = "id2" - record2.payload = { - "page_content": "text2", - "metadata": {"source": "test2"}, - } - - # First call returns records with offset, second returns empty with None offset - mock_ds.client.scroll.side_effect = [ - ([record1, record2], None), - ] - - chunks = store.get_chunks() - - assert len(chunks) == 2 - assert chunks[0] == { - "doc_id": "id1", - "text": "text1", - "metadata": {"source": "test"}, - } - - def test_get_chunks_pagination(self): - store, mock_ds, _ = _make_qdrant_store() - - record1 = MagicMock() - record1.id = "id1" - record1.payload = {"page_content": "text1", "metadata": {}} - - record2 = MagicMock() - record2.id = "id2" - record2.payload = {"page_content": "text2", "metadata": {}} - - mock_ds.client.scroll.side_effect = [ - ([record1], "offset_token"), - ([record2], None), - ] - - chunks = store.get_chunks() - assert len(chunks) == 2 - assert mock_ds.client.scroll.call_count == 2 - - def test_get_chunks_returns_empty_on_error(self): - store, mock_ds, _ = _make_qdrant_store() - mock_ds.client.scroll.side_effect = Exception("fail") - - assert store.get_chunks() == [] - - -@pytest.mark.unit -class TestQdrantStoreAddChunk: - def test_add_chunk(self): - store, mock_ds, _ = _make_qdrant_store(source_id="src1") - mock_ds.add_documents.return_value = ["new-id"] - - result = store.add_chunk("hello", metadata={"key": "val"}) - - assert result == "new-id" - mock_ds.add_documents.assert_called_once() - doc = mock_ds.add_documents.call_args[0][0][0] - assert doc.page_content == "hello" - assert doc.metadata["source_id"] == "src1" - assert doc.metadata["key"] == "val" - - def test_add_chunk_default_metadata(self): - store, mock_ds, _ = _make_qdrant_store(source_id="src1") - mock_ds.add_documents.return_value = ["id"] - - store.add_chunk("text") - - doc = mock_ds.add_documents.call_args[0][0][0] - assert doc.metadata["source_id"] == "src1" - - def test_add_chunk_fallback_id(self): - store, mock_ds, _ = _make_qdrant_store() - mock_ds.add_documents.return_value = [] - - result = store.add_chunk("text") - # Should return the uuid that was generated - assert result is not None - assert isinstance(result, str) - - -@pytest.mark.unit -class TestQdrantStoreDeleteChunk: - def test_delete_chunk_success(self): - store, mock_ds, _ = _make_qdrant_store() - - with patch("application.vectorstore.qdrant.settings") as ms: - ms.QDRANT_COLLECTION_NAME = "test_collection" - result = store.delete_chunk("chunk-id") - - mock_ds.client.delete.assert_called_once_with( - collection_name="test_collection", - points_selector=["chunk-id"], - ) - assert result is True - - def test_delete_chunk_returns_false_on_error(self): - store, mock_ds, _ = _make_qdrant_store() - - with patch("application.vectorstore.qdrant.settings") as ms: - ms.QDRANT_COLLECTION_NAME = "test_collection" - mock_ds.client.delete.side_effect = Exception("fail") - result = store.delete_chunk("bad-id") - - assert result is False + with patch("application.vectorstore.qdrant.settings") as mock_settings: + _settings(mock_settings) + mock_settings.QDRANT_LOCATION = None + mock_settings.QDRANT_URL = "http://qdrant:6333" + mock_settings.QDRANT_API_KEY = "secret" + kwargs = QdrantStore._client_kwargs() + assert kwargs["url"] == "http://qdrant:6333" + assert kwargs["api_key"] == "secret" + assert "location" not in kwargs From 209c29d75924bca9637f898db2b6ab5f93365f6e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 10 Aug 2026 12:37:40 +0100 Subject: [PATCH 4/4] fix: test --- tests/test_remaining_coverage.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_remaining_coverage.py b/tests/test_remaining_coverage.py index 61ed844b..ff5befe6 100644 --- a/tests/test_remaining_coverage.py +++ b/tests/test_remaining_coverage.py @@ -1049,19 +1049,31 @@ class TestEmbeddingPipelineCoverage: embed_and_store_documents([], str(tmp_path / "test"), "src-1", None) def test_embed_and_store_creates_folder(self, tmp_path): - """Cover line 65: folder creation.""" + """Cover line 65: the folder is created before the vector store is built. + + The failure is forced through ``VectorCreator`` rather than left to + whatever the store happens to reject — this previously relied on + langchain's ``from_documents`` reading a ``.id`` the stub lacks, so it + stopped failing the moment the FAISS store stopped going via langchain. + """ + import os + from application.parser.embedding_pipeline import embed_and_store_documents folder = str(tmp_path / "new_dir") - with pytest.raises(Exception): - # Will fail at VectorCreator but folder should be created - embed_and_store_documents( - [type("Doc", (), {"page_content": "text", "metadata": {}})()], - folder, - "src-1", - None, + with patch( + "application.parser.embedding_pipeline.VectorCreator" + ) as mock_vc: + mock_vc.create_vectorstore.side_effect = RuntimeError( + "vector store unavailable" ) - import os + with pytest.raises(RuntimeError, match="vector store unavailable"): + embed_and_store_documents( + [type("Doc", (), {"page_content": "text", "metadata": {}})()], + folder, + "src-1", + None, + ) assert os.path.exists(folder)