From 2aee7f9c746beaee7752f5bf51a8082b02f0b6c3 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:45:08 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF --- src/documents/tests/search/test_acceptance.py | 112 ++++++++++++++++++ src/documents/tests/search/test_query.py | 9 -- src/documents/tests/search/test_schema.py | 22 ++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/documents/tests/search/test_acceptance.py b/src/documents/tests/search/test_acceptance.py index 67493939d..cc374a58d 100644 --- a/src/documents/tests/search/test_acceptance.py +++ b/src/documents/tests/search/test_acceptance.py @@ -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} diff --git a/src/documents/tests/search/test_query.py b/src/documents/tests/search/test_query.py index 36a5f46ba..0c0479cfb 100644 --- a/src/documents/tests/search/test_query.py +++ b/src/documents/tests/search/test_query.py @@ -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, diff --git a/src/documents/tests/search/test_schema.py b/src/documents/tests/search/test_schema.py index 83ad17ab3..ac8798139 100644 --- a/src/documents/tests/search/test_schema.py +++ b/src/documents/tests/search/test_schema.py @@ -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]}" + )