"""Bare notes:/custom_fields: prefix resolution. "notes:foo"/"custom_fields:foo" were valid fielded searches before the whoosh-compat migration. The registry only exposes them as JSON subpaths, so each JSON FieldSpec declares a default subpath (SubpathSpec(default=True)): notes: resolves to notes.note:, custom_fields: resolves to custom_fields.value:. This replaced an earlier regex-based rewrite (_rewrite_bare_json_field_prefixes) that ran on the raw query string before parsing and was blind to quoting, so a phrase like content:"payment notes: none" was silently corrupted into a notes-field search and matched nothing. Resolving the default subpath inside the parser instead means quoting is already understood by the time it happens. """ from __future__ import annotations from typing import TYPE_CHECKING import pytest from documents.models import CustomField from documents.models import CustomFieldInstance from documents.models import Note from paperless_testing.factories import DocumentFactory from paperless_testing.factories import UserFactory if TYPE_CHECKING: from collections.abc import Callable from documents.models import Document from documents.search._backend import TantivyBackend pytestmark = [pytest.mark.search, pytest.mark.django_db] class TestBareJsonFieldPrefixes: def test_bare_notes_prefix_searches_note_text( self, backend: TantivyBackend, index_document: Callable[..., Document], matched_ids: Callable[[str], set[int]], ) -> None: """ GIVEN: - A document with a note whose text contains a word, and a decoy document whose content (not notes) contains the same word WHEN: - A bare "notes:" prefix query is run (notes declares "note" as its default subpath) THEN: - Only the document whose note matches is returned; the decoy's content match does not resurface through a demoted text search """ alice = UserFactory(username="alice") with_note = DocumentFactory(title="Has note", content="x") Note.objects.create(document=with_note, user=alice, note="crocodile") backend.add_or_update(with_note) # This document's CONTENT contains the words a demoted text search # would match; it must NOT match once the prefix addresses notes. index_document( title="Notes about things", content="notes crocodile mention", ) assert matched_ids("notes:crocodile") == {with_note.pk} def test_bare_custom_fields_prefix_searches_values( self, backend: TantivyBackend, index_document: Callable[..., Document], matched_ids: Callable[[str], set[int]], ) -> None: """ GIVEN: - A document with a custom field instance whose value contains a word, and a decoy document whose content (not a custom field value) contains the same word WHEN: - A bare "custom_fields:" prefix query is run (custom_fields declares "value" as its default subpath) THEN: - Only the document whose custom field value matches is returned """ field = CustomField.objects.create( name="Policy Number", data_type=CustomField.FieldDataType.STRING, ) with_value = DocumentFactory(title="Has field", content="x") CustomFieldInstance.objects.create( document=with_value, field=field, value_text="crocodile", ) backend.add_or_update(with_value) index_document( title="Custom things", content="custom fields crocodile", ) assert matched_ids("custom_fields:crocodile") == {with_value.pk} def test_subpath_spellings_are_untouched( self, backend: TantivyBackend, matched_ids: Callable[[str], set[int]], ) -> None: """ GIVEN: - A document with a note carrying both an author and note text WHEN: - The explicit subpath spellings "notes.user:" and "notes.note:" are queried THEN: - Both resolve to their intended subpath and match the document; the default-subpath resolution for the bare prefix does not interfere with explicit subpath addressing """ bob = UserFactory(username="bob") doc = DocumentFactory(title="Bob note", content="x") Note.objects.create(document=doc, user=bob, note="remark") backend.add_or_update(doc) assert matched_ids("notes.user:bob") == {doc.pk} assert matched_ids("notes.note:remark") == {doc.pk} class TestQuotedPhraseContainingNotesColonIsNotCorrupted: """The regex rewrite this migration removes was blind to quoting: it matched "notes:" anywhere in the raw query string, including inside an already-quoted phrase on an unrelated field, silently turning content:"payment notes: none" into a notes-field search that matched nothing. Resolving the default subpath during parsing (which is quote-aware) fixes this.""" def test_quoted_phrase_with_notes_colon_matches_by_content( self, index_document: Callable[..., Document], matched_ids: Callable[[str], set[int]], ) -> None: """ GIVEN: - A document whose content literally contains the text "payment notes: none" inside a quoted phrase WHEN: - A query quoting that exact phrase against the content field is run THEN: - It matches by content, rather than the "notes:" substring inside the quotes being corrupted into a notes-field search that matches nothing (the bug the deleted regex rewrite caused, since it was blind to quoting) """ target = index_document( title="Statement", content="payment notes: none", ) assert matched_ids('content:"payment notes: none"') == {target.pk} def test_quoted_phrase_matches_the_same_document_unquoted( self, index_document: Callable[..., Document], matched_ids: Callable[[str], set[int]], ) -> None: """ GIVEN: - A document whose content contains the same words as the previous test's phrase, but without the colon WHEN: - A query quoting that phrase against the content field is run THEN: - It matches by content, proving the earlier fix is about quote-awareness specifically, not about the words themselves being unsearchable """ target = index_document( title="Statement", content="payment notes none", ) assert matched_ids('content:"payment notes none"') == {target.pk}