mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-17 06:22:02 -04:00
756 lines
23 KiB
Python
756 lines
23 KiB
Python
import pytest
|
|
from django.contrib.auth.models import User
|
|
|
|
from documents.models import CustomField
|
|
from documents.models import CustomFieldInstance
|
|
from documents.models import Document
|
|
from documents.models import Note
|
|
from documents.search._backend import SearchMode
|
|
from documents.search._backend import TantivyBackend
|
|
from documents.search._backend import get_backend
|
|
from documents.search._backend import reset_backend
|
|
|
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
|
|
|
|
|
class TestWriteBatch:
|
|
"""Test WriteBatch context manager functionality."""
|
|
|
|
def test_rolls_back_on_exception(self, backend: TantivyBackend):
|
|
"""Batch operations must rollback on exception to preserve index integrity."""
|
|
doc = Document.objects.create(
|
|
title="Rollback Target",
|
|
content="should survive",
|
|
checksum="RB1",
|
|
pk=1,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
try:
|
|
with backend.batch_update() as batch:
|
|
batch.remove(doc.pk)
|
|
raise RuntimeError("simulated failure")
|
|
except RuntimeError:
|
|
pass
|
|
|
|
r = backend.search(
|
|
"should survive",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert r.total == 1
|
|
|
|
|
|
class TestSearch:
|
|
"""Test search functionality."""
|
|
|
|
def test_text_mode_limits_default_search_to_title_and_content(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Simple text mode must not match metadata-only fields."""
|
|
doc = Document.objects.create(
|
|
title="Invoice document",
|
|
content="monthly statement",
|
|
checksum="TXT1",
|
|
pk=9,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
metadata_only = backend.search(
|
|
"document_type:invoice",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert metadata_only.total == 0
|
|
|
|
content_match = backend.search(
|
|
"monthly",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert content_match.total == 1
|
|
|
|
def test_title_mode_limits_default_search_to_title_only(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Title mode must not match content-only terms."""
|
|
doc = Document.objects.create(
|
|
title="Invoice document",
|
|
content="monthly statement",
|
|
checksum="TXT2",
|
|
pk=10,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
content_only = backend.search(
|
|
"monthly",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TITLE,
|
|
)
|
|
assert content_only.total == 0
|
|
|
|
title_match = backend.search(
|
|
"invoice",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TITLE,
|
|
)
|
|
assert title_match.total == 1
|
|
|
|
def test_text_mode_matches_partial_term_substrings(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Simple text mode should support substring matching within tokens."""
|
|
doc = Document.objects.create(
|
|
title="Account access",
|
|
content="password reset instructions",
|
|
checksum="TXT3",
|
|
pk=11,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
prefix_match = backend.search(
|
|
"pass",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert prefix_match.total == 1
|
|
|
|
infix_match = backend.search(
|
|
"sswo",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert infix_match.total == 1
|
|
|
|
phrase_match = backend.search(
|
|
"sswo re",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert phrase_match.total == 1
|
|
|
|
def test_text_mode_does_not_match_on_partial_term_overlap(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Simple text mode should not match documents that merely share partial fragments."""
|
|
doc = Document.objects.create(
|
|
title="Adobe Acrobat PDF Files",
|
|
content="Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
checksum="TXT7",
|
|
pk=13,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
non_match = backend.search(
|
|
"raptor",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert non_match.total == 0
|
|
|
|
def test_text_mode_anchors_later_query_tokens_to_token_starts(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Multi-token simple search should not match later tokens in the middle of a word."""
|
|
exact_doc = Document.objects.create(
|
|
title="Z-Berichte 6",
|
|
content="monthly report",
|
|
checksum="TXT9",
|
|
pk=15,
|
|
)
|
|
prefix_doc = Document.objects.create(
|
|
title="Z-Berichte 60",
|
|
content="monthly report",
|
|
checksum="TXT10",
|
|
pk=16,
|
|
)
|
|
false_positive = Document.objects.create(
|
|
title="Z-Berichte 16",
|
|
content="monthly report",
|
|
checksum="TXT11",
|
|
pk=17,
|
|
)
|
|
backend.add_or_update(exact_doc)
|
|
backend.add_or_update(prefix_doc)
|
|
backend.add_or_update(false_positive)
|
|
|
|
results = backend.search(
|
|
"Z-Berichte 6",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
result_ids = {hit["id"] for hit in results.hits}
|
|
|
|
assert exact_doc.id in result_ids
|
|
assert prefix_doc.id in result_ids
|
|
assert false_positive.id not in result_ids
|
|
|
|
def test_text_mode_ignores_queries_without_searchable_tokens(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Simple text mode should safely return no hits for symbol-only strings."""
|
|
doc = Document.objects.create(
|
|
title="Guide",
|
|
content="This is a guide.",
|
|
checksum="TXT8",
|
|
pk=14,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
no_tokens = backend.search(
|
|
"!!!",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TEXT,
|
|
)
|
|
assert no_tokens.total == 0
|
|
|
|
def test_title_mode_matches_partial_term_substrings(
|
|
self,
|
|
backend: TantivyBackend,
|
|
):
|
|
"""Title mode should support substring matching within title tokens."""
|
|
doc = Document.objects.create(
|
|
title="Password guide",
|
|
content="reset instructions",
|
|
checksum="TXT4",
|
|
pk=12,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
prefix_match = backend.search(
|
|
"pass",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TITLE,
|
|
)
|
|
assert prefix_match.total == 1
|
|
|
|
infix_match = backend.search(
|
|
"sswo",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TITLE,
|
|
)
|
|
assert infix_match.total == 1
|
|
|
|
phrase_match = backend.search(
|
|
"sswo gu",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
search_mode=SearchMode.TITLE,
|
|
)
|
|
assert phrase_match.total == 1
|
|
|
|
def test_scores_normalised_top_hit_is_one(self, backend: TantivyBackend):
|
|
"""Search scores must be normalized so top hit has score 1.0 for UI consistency."""
|
|
for i, title in enumerate(["bank invoice", "bank statement", "bank receipt"]):
|
|
doc = Document.objects.create(
|
|
title=title,
|
|
content=title,
|
|
checksum=f"SN{i}",
|
|
pk=10 + i,
|
|
)
|
|
backend.add_or_update(doc)
|
|
r = backend.search(
|
|
"bank",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert r.hits[0]["score"] == pytest.approx(1.0)
|
|
assert all(0.0 <= h["score"] <= 1.0 for h in r.hits)
|
|
|
|
def test_sort_field_ascending(self, backend: TantivyBackend):
|
|
"""Searching with sort_reverse=False must return results in ascending ASN order."""
|
|
for asn in [30, 10, 20]:
|
|
doc = Document.objects.create(
|
|
title="sortable",
|
|
content="sortable content",
|
|
checksum=f"SFA{asn}",
|
|
archive_serial_number=asn,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
r = backend.search(
|
|
"sortable",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field="archive_serial_number",
|
|
sort_reverse=False,
|
|
)
|
|
assert r.total == 3
|
|
asns = [Document.objects.get(pk=h["id"]).archive_serial_number for h in r.hits]
|
|
assert asns == [10, 20, 30]
|
|
|
|
def test_sort_field_descending(self, backend: TantivyBackend):
|
|
"""Searching with sort_reverse=True must return results in descending ASN order."""
|
|
for asn in [30, 10, 20]:
|
|
doc = Document.objects.create(
|
|
title="sortable",
|
|
content="sortable content",
|
|
checksum=f"SFD{asn}",
|
|
archive_serial_number=asn,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
r = backend.search(
|
|
"sortable",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field="archive_serial_number",
|
|
sort_reverse=True,
|
|
)
|
|
assert r.total == 3
|
|
asns = [Document.objects.get(pk=h["id"]).archive_serial_number for h in r.hits]
|
|
assert asns == [30, 20, 10]
|
|
|
|
def test_fuzzy_threshold_filters_low_score_hits(
|
|
self,
|
|
backend: TantivyBackend,
|
|
settings,
|
|
):
|
|
"""When ADVANCED_FUZZY_SEARCH_THRESHOLD exceeds all normalized scores, hits must be filtered out."""
|
|
doc = Document.objects.create(
|
|
title="Invoice document",
|
|
content="financial report",
|
|
checksum="FT1",
|
|
pk=120,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
# Threshold above 1.0 filters every hit (normalized scores top out at 1.0)
|
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 1.1
|
|
r = backend.search(
|
|
"invoice",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert r.hits == []
|
|
|
|
def test_owner_filter(self, backend: TantivyBackend):
|
|
"""Document owners can search their private documents; other users cannot access them."""
|
|
owner = User.objects.create_user("owner")
|
|
other = User.objects.create_user("other")
|
|
doc = Document.objects.create(
|
|
title="Private",
|
|
content="secret",
|
|
checksum="PF1",
|
|
pk=20,
|
|
owner=owner,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
assert (
|
|
backend.search(
|
|
"secret",
|
|
user=owner,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
).total
|
|
== 1
|
|
)
|
|
assert (
|
|
backend.search(
|
|
"secret",
|
|
user=other,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
).total
|
|
== 0
|
|
)
|
|
|
|
|
|
class TestRebuild:
|
|
"""Test index rebuilding functionality."""
|
|
|
|
def test_with_iter_wrapper_called(self, backend: TantivyBackend):
|
|
"""Index rebuild must pass documents through iter_wrapper for progress tracking."""
|
|
seen = []
|
|
|
|
def wrapper(docs):
|
|
for doc in docs:
|
|
seen.append(doc.pk)
|
|
yield doc
|
|
|
|
Document.objects.create(title="Tracked", content="x", checksum="TW1", pk=30)
|
|
backend.rebuild(Document.objects.all(), iter_wrapper=wrapper)
|
|
assert 30 in seen
|
|
|
|
|
|
class TestAutocomplete:
|
|
"""Test autocomplete functionality."""
|
|
|
|
def test_basic_functionality(self, backend: TantivyBackend):
|
|
"""Autocomplete must return words matching the given prefix."""
|
|
doc = Document.objects.create(
|
|
title="Invoice from Microsoft Corporation",
|
|
content="payment details",
|
|
checksum="AC1",
|
|
pk=40,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
results = backend.autocomplete("micro", limit=10)
|
|
assert "microsoft" in results
|
|
|
|
def test_results_ordered_by_document_frequency(self, backend: TantivyBackend):
|
|
"""Autocomplete results must be ordered by document frequency to prioritize common terms."""
|
|
# "payment" appears in 3 docs; "payslip" in 1 — "pay" prefix should
|
|
# return "payment" before "payslip".
|
|
for i, (title, checksum) in enumerate(
|
|
[
|
|
("payment invoice", "AF1"),
|
|
("payment receipt", "AF2"),
|
|
("payment confirmation", "AF3"),
|
|
("payslip march", "AF4"),
|
|
],
|
|
start=41,
|
|
):
|
|
doc = Document.objects.create(
|
|
title=title,
|
|
content="details",
|
|
checksum=checksum,
|
|
pk=i,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
results = backend.autocomplete("pay", limit=10)
|
|
assert results.index("payment") < results.index("payslip")
|
|
|
|
|
|
class TestMoreLikeThis:
|
|
"""Test more like this functionality."""
|
|
|
|
def test_excludes_original(self, backend: TantivyBackend):
|
|
"""More like this queries must exclude the reference document from results."""
|
|
doc1 = Document.objects.create(
|
|
title="Important document",
|
|
content="financial information",
|
|
checksum="MLT1",
|
|
pk=50,
|
|
)
|
|
doc2 = Document.objects.create(
|
|
title="Another document",
|
|
content="financial report",
|
|
checksum="MLT2",
|
|
pk=51,
|
|
)
|
|
backend.add_or_update(doc1)
|
|
backend.add_or_update(doc2)
|
|
|
|
results = backend.more_like_this(doc_id=50, user=None, page=1, page_size=10)
|
|
returned_ids = [hit["id"] for hit in results.hits]
|
|
assert 50 not in returned_ids # Original document excluded
|
|
|
|
def test_with_user_applies_permission_filter(self, backend: TantivyBackend):
|
|
"""more_like_this with a user must exclude documents that user cannot see."""
|
|
viewer = User.objects.create_user("mlt_viewer")
|
|
other = User.objects.create_user("mlt_other")
|
|
public_doc = Document.objects.create(
|
|
title="Public financial document",
|
|
content="quarterly financial analysis report figures",
|
|
checksum="MLT3",
|
|
pk=52,
|
|
)
|
|
private_doc = Document.objects.create(
|
|
title="Private financial document",
|
|
content="quarterly financial analysis report figures",
|
|
checksum="MLT4",
|
|
pk=53,
|
|
owner=other,
|
|
)
|
|
backend.add_or_update(public_doc)
|
|
backend.add_or_update(private_doc)
|
|
|
|
results = backend.more_like_this(doc_id=52, user=viewer, page=1, page_size=10)
|
|
returned_ids = [hit["id"] for hit in results.hits]
|
|
# private_doc is owned by other, so viewer cannot see it
|
|
assert 53 not in returned_ids
|
|
|
|
def test_document_not_in_index_returns_empty(self, backend: TantivyBackend):
|
|
"""more_like_this for a doc_id absent from the index must return empty results."""
|
|
results = backend.more_like_this(doc_id=9999, user=None, page=1, page_size=10)
|
|
assert results.hits == []
|
|
assert results.total == 0
|
|
|
|
|
|
class TestSingleton:
|
|
"""Test get_backend() and reset_backend() singleton lifecycle."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean(self):
|
|
reset_backend()
|
|
yield
|
|
reset_backend()
|
|
|
|
def test_returns_same_instance_on_repeated_calls(self, index_dir):
|
|
"""Singleton pattern: repeated calls to get_backend() must return the same instance."""
|
|
assert get_backend() is get_backend()
|
|
|
|
def test_reinitializes_when_index_dir_changes(self, tmp_path, settings):
|
|
"""Backend singleton must reinitialize when INDEX_DIR setting changes for test isolation."""
|
|
settings.INDEX_DIR = tmp_path / "a"
|
|
(tmp_path / "a").mkdir()
|
|
b1 = get_backend()
|
|
|
|
settings.INDEX_DIR = tmp_path / "b"
|
|
(tmp_path / "b").mkdir()
|
|
b2 = get_backend()
|
|
|
|
assert b1 is not b2
|
|
assert b2._path == tmp_path / "b"
|
|
|
|
def test_reset_forces_new_instance(self, index_dir):
|
|
"""reset_backend() must force creation of a new backend instance on next get_backend() call."""
|
|
b1 = get_backend()
|
|
reset_backend()
|
|
b2 = get_backend()
|
|
assert b1 is not b2
|
|
|
|
|
|
class TestFieldHandling:
|
|
"""Test handling of various document fields."""
|
|
|
|
def test_none_values_handled_correctly(self, backend: TantivyBackend):
|
|
"""Document fields with None values must not cause indexing errors."""
|
|
doc = Document.objects.create(
|
|
title="Test Doc",
|
|
content="test content",
|
|
checksum="NV1",
|
|
pk=60,
|
|
original_filename=None,
|
|
page_count=None,
|
|
)
|
|
# Should not raise an exception
|
|
backend.add_or_update(doc)
|
|
|
|
results = backend.search(
|
|
"test",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 1
|
|
|
|
def test_custom_fields_include_name_and_value(self, backend: TantivyBackend):
|
|
"""Custom fields must be indexed with both field name and value for structured queries."""
|
|
# Create a custom field
|
|
field = CustomField.objects.create(
|
|
name="Invoice Number",
|
|
data_type=CustomField.FieldDataType.STRING,
|
|
)
|
|
doc = Document.objects.create(
|
|
title="Invoice",
|
|
content="test",
|
|
checksum="CF1",
|
|
pk=70,
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=field,
|
|
value_text="INV-2024-001",
|
|
)
|
|
|
|
# Should not raise an exception during indexing
|
|
backend.add_or_update(doc)
|
|
|
|
results = backend.search(
|
|
"invoice",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 1
|
|
|
|
def test_select_custom_field_indexes_label_not_id(self, backend: TantivyBackend):
|
|
"""SELECT custom fields must index the human-readable label, not the opaque option ID."""
|
|
field = CustomField.objects.create(
|
|
name="Category",
|
|
data_type=CustomField.FieldDataType.SELECT,
|
|
extra_data={
|
|
"select_options": [
|
|
{"id": "opt_abc", "label": "Invoice"},
|
|
{"id": "opt_def", "label": "Receipt"},
|
|
],
|
|
},
|
|
)
|
|
doc = Document.objects.create(
|
|
title="Categorised doc",
|
|
content="test",
|
|
checksum="SEL1",
|
|
pk=71,
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=field,
|
|
value_select="opt_abc",
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
# Label should be findable
|
|
results = backend.search(
|
|
"custom_fields.value:invoice",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 1
|
|
|
|
# Opaque ID must not appear in the index
|
|
results = backend.search(
|
|
"custom_fields.value:opt_abc",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 0
|
|
|
|
def test_none_custom_field_value_not_indexed(self, backend: TantivyBackend):
|
|
"""Custom field instances with no value set must not produce an index entry."""
|
|
field = CustomField.objects.create(
|
|
name="Optional",
|
|
data_type=CustomField.FieldDataType.SELECT,
|
|
extra_data={"select_options": [{"id": "opt_1", "label": "Yes"}]},
|
|
)
|
|
doc = Document.objects.create(
|
|
title="Unset field doc",
|
|
content="test",
|
|
checksum="SEL2",
|
|
pk=72,
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=field,
|
|
value_select=None,
|
|
)
|
|
backend.add_or_update(doc)
|
|
|
|
# The string "none" must not appear as an indexed value
|
|
results = backend.search(
|
|
"custom_fields.value:none",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 0
|
|
|
|
def test_notes_include_user_information(self, backend: TantivyBackend):
|
|
"""Notes must be indexed with user information when available for structured queries."""
|
|
user = User.objects.create_user("notewriter")
|
|
doc = Document.objects.create(
|
|
title="Doc with notes",
|
|
content="test",
|
|
checksum="NT1",
|
|
pk=80,
|
|
)
|
|
Note.objects.create(document=doc, note="Important note", user=user)
|
|
|
|
# Should not raise an exception during indexing
|
|
backend.add_or_update(doc)
|
|
|
|
# Test basic document search first
|
|
results = backend.search(
|
|
"test",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 1, (
|
|
f"Expected 1, got {results.total}. Document content should be searchable."
|
|
)
|
|
|
|
# Test notes search — must use structured JSON syntax now that note
|
|
# is no longer in DEFAULT_SEARCH_FIELDS
|
|
results = backend.search(
|
|
"notes.note:important",
|
|
user=None,
|
|
page=1,
|
|
page_size=10,
|
|
sort_field=None,
|
|
sort_reverse=False,
|
|
)
|
|
assert results.total == 1, (
|
|
f"Expected 1, got {results.total}. Note content should be searchable via notes.note: prefix."
|
|
)
|