mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-19 17:23:21 +00:00
test(search): harden coverage for aliases, fast flags, and date edges
Four targeted additions, no production code: The type-alias test asserted only that a query object was built, and a naive result-level replacement turned out equally vacuous for a subtle reason: document_type is itself a default search field, so a broken alias resolution demoting "type:invoice" to unfielded text STILL matches the typed document through the field value under test. Both alias tests (type/document_type, path/storage_path) now use discriminating decoys carrying the query word in content, so demotion matches the decoy and fails the exact-set assertion; the old parse-shape test is deleted. A new schema test pins that every PublicField.fast flag equals the built tantivy schema's per-field fast option, in both drift directions: whoosh-compat trusts the declared flag when resolving field:* existence checks, and build_schema() only honors it for U64 and DATE kinds, so a future fast=True TEXT/KEYWORD/JSON entry would otherwise make those searches silently match nothing at query time. Two result-level date pins restore behaviors whose assertions were lost in the test migration: a created date matches regardless of the active timezone (the America/New_York leg is the discriminating one: a tz-applying implementation shifts the window past the naive-midnight indexed value), and a reversed created:[2025 TO 2020] range still matches its span through the joint-disambiguation swap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
This commit is contained in:
co-authored by
Claude Fable 5
parent
1cb07030b0
commit
2aee7f9c74
@@ -583,3 +583,115 @@ class TestBareJsonFieldPrefixes:
|
||||
backend.add_or_update(doc)
|
||||
assert _matched_ids(backend, "notes.user:bob") == {doc.pk}
|
||||
assert _matched_ids(backend, "notes.note:remark") == {doc.pk}
|
||||
|
||||
|
||||
class TestFieldAliases:
|
||||
"""type:/path: are registry aliases for document_type:/storage_path:.
|
||||
The only other alias coverage is parse-shape; these prove resolution
|
||||
end-to-end against a real index."""
|
||||
|
||||
def test_type_alias_and_canonical_name_match_the_same_document(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
from documents.models import DocumentType
|
||||
|
||||
invoice_type = DocumentType.objects.create(name="invoice")
|
||||
# Discriminating shape: document_type is itself a default search
|
||||
# field, so if alias resolution ever broke and "type:invoice"
|
||||
# demoted to unfielded text, the token would STILL match the typed
|
||||
# document through the field value. The decoy carries the query
|
||||
# word in content, so a demoted search matches BOTH documents and
|
||||
# the exact-set assertions fail. (The title avoids stemming to
|
||||
# "type": english stems Typed -> type.)
|
||||
typed = Document.objects.create(
|
||||
title="First",
|
||||
content="quarterly statement",
|
||||
checksum="alias-type-1",
|
||||
document_type=invoice_type,
|
||||
)
|
||||
decoy = Document.objects.create(
|
||||
title="Second",
|
||||
content="invoice mentioned in body",
|
||||
checksum="alias-type-2",
|
||||
)
|
||||
backend.add_or_update(typed)
|
||||
backend.add_or_update(decoy)
|
||||
assert _matched_ids(backend, "type:invoice") == {typed.pk}
|
||||
assert _matched_ids(backend, "document_type:invoice") == {typed.pk}
|
||||
|
||||
def test_path_alias_and_canonical_name_match_the_same_document(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
from documents.models import StoragePath
|
||||
|
||||
archive = StoragePath.objects.create(name="archive", path="archive/{title}")
|
||||
stored = Document.objects.create(
|
||||
title="Stored",
|
||||
content="quarterly statement",
|
||||
checksum="alias-path-1",
|
||||
storage_path=archive,
|
||||
)
|
||||
# storage_path is NOT a default search field today, so a demoted
|
||||
# "path:archive" already matches nothing; the content decoy keeps
|
||||
# this test discriminating even if it ever joins the defaults.
|
||||
loose = Document.objects.create(
|
||||
title="Loose",
|
||||
content="archive mentioned in body",
|
||||
checksum="alias-path-2",
|
||||
)
|
||||
backend.add_or_update(stored)
|
||||
backend.add_or_update(loose)
|
||||
assert _matched_ids(backend, "path:archive") == {stored.pk}
|
||||
assert _matched_ids(backend, "storage_path:archive") == {stored.pk}
|
||||
|
||||
|
||||
class TestCreatedTimezoneInvariance:
|
||||
def test_created_date_matches_regardless_of_active_timezone(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
# "created" is a date-only field indexed at naive midnight: a
|
||||
# document created 2020-06-10 must match created:20200610 whether
|
||||
# the active timezone is far ahead of or behind UTC. (The
|
||||
# timezone-SENSITIVE datetime fields have their own boundary
|
||||
# coverage in test_api_search.py's tz-ahead/tz-behind tests.)
|
||||
from django.utils import timezone as django_tz
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="Dated",
|
||||
content="x",
|
||||
checksum="tz-inv-1",
|
||||
created=date(2020, 6, 10),
|
||||
)
|
||||
backend.add_or_update(doc)
|
||||
for tzname in ("Pacific/Auckland", "America/New_York"):
|
||||
with django_tz.override(tzname):
|
||||
assert _matched_ids(backend, "created:20200610") == {doc.pk}, tzname
|
||||
|
||||
|
||||
class TestReversedDateRange:
|
||||
def test_reversed_bounds_still_match_the_span(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
# whoosh's joint disambiguation swaps backwards bounds (both years
|
||||
# explicit -> plain swap), and whoosh-compat reproduces it; a saved
|
||||
# view with created:[2025 TO 2020] must keep matching the span
|
||||
# instead of becoming an empty lo>hi range.
|
||||
inside = Document.objects.create(
|
||||
title="Inside",
|
||||
content="x",
|
||||
checksum="rev-range-1",
|
||||
created=date(2022, 5, 1),
|
||||
)
|
||||
outside = Document.objects.create(
|
||||
title="Outside",
|
||||
content="x",
|
||||
checksum="rev-range-2",
|
||||
created=date(2019, 5, 1),
|
||||
)
|
||||
backend.add_or_update(inside)
|
||||
backend.add_or_update(outside)
|
||||
assert _matched_ids(backend, "created:[2025 TO 2020]") == {inside.pk}
|
||||
|
||||
@@ -126,15 +126,6 @@ class TestParseUserQuery:
|
||||
kinds = {type(e) for e in exc_info.value.errors}
|
||||
assert kinds == {InvalidDateQuery, InvalidNumberQuery}
|
||||
|
||||
def test_document_type_query_via_type_alias_matches(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
) -> None:
|
||||
# Field alias handling now goes through the FieldRegistry, not
|
||||
# FIELD_ALIASES string substitution — prove it still resolves.
|
||||
q = parse_user_query(query_index, "type:invoice", UTC)
|
||||
assert isinstance(q, tantivy.Query)
|
||||
|
||||
def test_asn_field_is_query_addressable(
|
||||
self,
|
||||
query_index: tantivy.Index,
|
||||
|
||||
@@ -126,3 +126,25 @@ class TestSchemaMatchesPublicFields:
|
||||
searcher = index.searcher()
|
||||
results = searcher.search(tantivy.Query.term_query(schema, "asn", 42), limit=1)
|
||||
assert len(results.hits) == 1
|
||||
|
||||
|
||||
class TestFastFlagAgreement:
|
||||
def test_every_public_field_fast_flag_matches_the_built_schema(self) -> None:
|
||||
# whoosh-compat's registry trusts PublicField.fast when resolving
|
||||
# field:* existence checks (its FAST_FIELD strategy); a fast=True
|
||||
# entry whose actual tantivy column is not fast would make those
|
||||
# searches silently match nothing at search time. build_schema()
|
||||
# only honors the flag in its U64 and DATE branches today, so this
|
||||
# pins the agreement for EVERY kind: a future fast=True
|
||||
# TEXT/KEYWORD/JSON entry the builder silently ignores fails here
|
||||
# instead of at a user's query.
|
||||
state = build_schema().__reduce__()[1][0]
|
||||
schema_fast = {
|
||||
field["name"]: bool(field["options"].get("fast", False))
|
||||
for field in state["inner"]
|
||||
}
|
||||
for public_field in PUBLIC_FIELDS:
|
||||
assert schema_fast[public_field.name] == public_field.fast, (
|
||||
f"{public_field.name}: PublicField.fast={public_field.fast} but the"
|
||||
f" built schema says fast={schema_fast[public_field.name]}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user