From f113cc030ff38d9fa7785a2d57ba886e7460d50e Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:15:48 -0700 Subject: [PATCH] Sure, fine cover these lines with tests --- src/paperless_ai/tests/test_ai_indexing.py | 40 ++++++++++ src/paperless_ai/tests/test_taxonomy.py | 86 ++++++++++++++++++++-- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py index 6b1dbecec..659eaa2f5 100644 --- a/src/paperless_ai/tests/test_ai_indexing.py +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -1079,6 +1079,46 @@ def test_retrieve_similar_nodes_returns_raw_nodes_from_retriever( assert nodes == [fake_node] +@pytest.mark.django_db +def test_retrieve_similar_nodes_drops_result_outside_allow_list( + mocker: pytest_mock.MockerFixture, +) -> None: + """ + GIVEN: + - An allow-list naming only one document + - A mocked retriever that returns a node for a DIFFERENT document + (as if the vec0-level MetadataFilters had failed to apply) + WHEN: + - retrieve_similar_nodes() is called with that allow-list + THEN: + - The out-of-allow-list node is dropped by this function's own + Python-level re-check, independent of whatever filtering the + vector store itself applied - this is the defense-in-depth layer + for a permission boundary, so it must work standalone. + """ + source = DocumentFactory.create() + allowed = DocumentFactory.create() + not_allowed = DocumentFactory.create() + allowed_node = mocker.MagicMock() + allowed_node.metadata = {"document_id": str(allowed.pk)} + disallowed_node = mocker.MagicMock() + disallowed_node.metadata = {"document_id": str(not_allowed.pk)} + mocker.patch("paperless_ai.indexing.llm_index_exists", return_value=True) + mock_retriever_cls = mocker.patch( + "llama_index.core.retrievers.VectorIndexRetriever", + ) + mock_retriever_cls.return_value.retrieve.return_value = [ + allowed_node, + disallowed_node, + ] + mocker.patch("paperless_ai.indexing.load_or_build_index") + mocker.patch("paperless_ai.indexing.read_store") + + nodes = indexing.retrieve_similar_nodes(source, document_ids=[allowed.pk]) + + assert nodes == [allowed_node] + + @pytest.mark.django_db def test_retrieve_similar_nodes_returns_empty_when_index_missing( mocker: pytest_mock.MockerFixture, diff --git a/src/paperless_ai/tests/test_taxonomy.py b/src/paperless_ai/tests/test_taxonomy.py index 470badc1c..26bcab7bc 100644 --- a/src/paperless_ai/tests/test_taxonomy.py +++ b/src/paperless_ai/tests/test_taxonomy.py @@ -273,16 +273,92 @@ class TestBuildTaxonomyCandidates: THEN: - Only 5 correspondents are returned """ - nodes = [] - for i in range(7): - correspondent = CorrespondentFactory.create(name=f"Corr{i}") - document = DocumentFactory.create(correspondent=correspondent) - nodes.append(make_node(document.pk, 0.5)) + correspondents = CorrespondentFactory.create_batch(7) + nodes = [ + make_node(DocumentFactory.create(correspondent=c).pk, 0.5) + for c in correspondents + ] result = build_taxonomy_candidates(nodes, user=None) assert len(result["correspondents"]) == 5 + def test_document_type_candidate_is_surfaced(self) -> None: + """ + GIVEN: + - A neighbour document with a document_type assigned + WHEN: + - build_taxonomy_candidates() is called + THEN: + - The document_type is returned as a candidate + """ + document_type = DocumentTypeFactory.create(name="Invoice") + document = DocumentFactory.create(document_type=document_type) + nodes = [make_node(document.pk, 0.5)] + + result = build_taxonomy_candidates(nodes, user=None) + + assert len(result["document_types"]) == 1 + assert result["document_types"][0]["id"] == document_type.pk + assert result["document_types"][0]["name"] == "Invoice" + + def test_document_type_candidates_capped_at_five(self) -> None: + """ + GIVEN: + - 7 documents with different document_types + WHEN: + - build_taxonomy_candidates() is called + THEN: + - Only 5 document_types are returned + """ + document_types = DocumentTypeFactory.create_batch(7) + nodes = [ + make_node(DocumentFactory.create(document_type=dt).pk, 0.5) + for dt in document_types + ] + + result = build_taxonomy_candidates(nodes, user=None) + + assert len(result["document_types"]) == 5 + + def test_storage_path_candidate_is_surfaced(self) -> None: + """ + GIVEN: + - A neighbour document with a storage_path assigned + WHEN: + - build_taxonomy_candidates() is called + THEN: + - The storage_path is returned as a candidate + """ + storage_path = StoragePathFactory.create(name="Invoices") + document = DocumentFactory.create(storage_path=storage_path) + nodes = [make_node(document.pk, 0.5)] + + result = build_taxonomy_candidates(nodes, user=None) + + assert len(result["storage_paths"]) == 1 + assert result["storage_paths"][0]["id"] == storage_path.pk + assert result["storage_paths"][0]["name"] == "Invoices" + + def test_storage_path_candidates_capped_at_five(self) -> None: + """ + GIVEN: + - 7 documents with different storage_paths + WHEN: + - build_taxonomy_candidates() is called + THEN: + - Only 5 storage_paths are returned + """ + storage_paths = StoragePathFactory.create_batch(7) + nodes = [ + make_node(DocumentFactory.create(storage_path=sp).pk, 0.5) + for sp in storage_paths + ] + + result = build_taxonomy_candidates(nodes, user=None) + + assert len(result["storage_paths"]) == 5 + def test_permission_filters_independent_of_neighbour_document_visibility( self, mocker: pytest_mock.MockerFixture,