diff --git a/src/documents/tests/search/test_comma_value_lists.py b/src/documents/tests/search/test_comma_value_lists.py new file mode 100644 index 000000000..3552a97fc --- /dev/null +++ b/src/documents/tests/search/test_comma_value_lists.py @@ -0,0 +1,139 @@ +"""Comma-separated value lists, at result level. + +``tag:foo,bar`` is a value list only for fields that opt into +``comma_values`` (``tag`` is the only one today). Nothing in the tree runs +a ``tag:`` fielded query against real documents; this covers foo-only, +bar-only and both-tags documents against a real index, plus a decoy +proving a non-comma_values field (``correspondent``) does NOT treat a +comma as a list separator -- ``correspondent:foo,bar`` searches for the +literal text "foo,bar", so a correspondent literally named that way +matches while foo-only/bar-only correspondents do not. + +The list semantics are AND (a document must carry every listed value), +not OR: a foo-only or bar-only document does not match ``tag:foo,bar``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from documents.models import Correspondent +from documents.models import Document +from documents.models import Tag + +if TYPE_CHECKING: + from documents.search._backend import TantivyBackend + +pytestmark = [pytest.mark.search, pytest.mark.django_db] + + +def _matched_ids(backend: TantivyBackend, query: str) -> set[int]: + return set(backend.search_ids(query, user=None)) + + +class TestTagCommaValueListIsConjunctive: + @pytest.fixture + def docs(self, backend: TantivyBackend) -> dict[str, int]: + foo = Tag.objects.create(name="foo") + bar = Tag.objects.create(name="bar") + baz = Tag.objects.create(name="baz") + + foo_only = Document.objects.create( + title="Foo only", + content="x", + checksum="comma-tag-foo-only", + ) + foo_only.tags.set([foo]) + backend.add_or_update(foo_only) + + bar_only = Document.objects.create( + title="Bar only", + content="x", + checksum="comma-tag-bar-only", + ) + bar_only.tags.set([bar]) + backend.add_or_update(bar_only) + + both = Document.objects.create( + title="Both tags", + content="x", + checksum="comma-tag-both", + ) + both.tags.set([foo, bar]) + backend.add_or_update(both) + + neither = Document.objects.create( + title="Neither tag", + content="x", + checksum="comma-tag-neither", + ) + neither.tags.set([baz]) + backend.add_or_update(neither) + + return { + "foo_only": foo_only.pk, + "bar_only": bar_only.pk, + "both": both.pk, + "neither": neither.pk, + } + + def test_only_the_document_carrying_both_tags_matches( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + assert _matched_ids(backend, "tag:foo,bar") == {docs["both"]} + + def test_foo_only_document_does_not_match( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + assert docs["foo_only"] not in _matched_ids(backend, "tag:foo,bar") + + def test_bar_only_document_does_not_match( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + assert docs["bar_only"] not in _matched_ids(backend, "tag:foo,bar") + + +class TestNonCommaValuesFieldTreatsCommaAsLiteralText: + def test_correspondent_comma_is_not_a_value_list( + self, + backend: TantivyBackend, + ) -> None: + literal = Correspondent.objects.create(name="foo,bar") + literal_doc = Document.objects.create( + title="Literal correspondent", + content="x", + checksum="comma-correspondent-literal", + correspondent=literal, + ) + backend.add_or_update(literal_doc) + + foo_correspondent = Correspondent.objects.create(name="foo") + foo_doc = Document.objects.create( + title="Foo correspondent", + content="x", + checksum="comma-correspondent-foo", + correspondent=foo_correspondent, + ) + backend.add_or_update(foo_doc) + + bar_correspondent = Correspondent.objects.create(name="bar") + bar_doc = Document.objects.create( + title="Bar correspondent", + content="x", + checksum="comma-correspondent-bar", + correspondent=bar_correspondent, + ) + backend.add_or_update(bar_doc) + + # A value-list reading would match foo_doc and/or bar_doc (an OR) + # or neither (an AND, since no single correspondent carries both + # values). Either way it would NOT match literal_doc alone. + assert _matched_ids(backend, "correspondent:foo,bar") == {literal_doc.pk} diff --git a/src/documents/tests/search/test_dash_prefix_negation.py b/src/documents/tests/search/test_dash_prefix_negation.py new file mode 100644 index 000000000..91e7190ae --- /dev/null +++ b/src/documents/tests/search/test_dash_prefix_negation.py @@ -0,0 +1,120 @@ +"""Pins the current, deferred-negation behavior of a leading ``-``. + +Negation via a bare ``-`` prefix (as opposed to the ``NOT`` keyword) is +deferred to the companion plan's G1 item. Until G1 lands, a leading ``-`` +is not negation at all: + +- Unfielded (``-taxes``): the separator is stripped at index time, so the + term becomes an ordinary, *required* positive match on ``taxes`` -- the + exact inverse of what a user typing ``-taxes`` to exclude a term intends. +- Fielded (``-title:alpha``): the leading hyphen detaches from the field + clause entirely (the parse tree emits it as its own token), and the + field clause itself is left as an ordinary positive match. The + negation is dropped, not inverted. + +If G1 changes either of these, the corresponding test below inverts -- +that inversion is the intended, visible signal that G1 landed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from documents.models import Document + +if TYPE_CHECKING: + from documents.search._backend import TantivyBackend + +pytestmark = [pytest.mark.search, pytest.mark.django_db] + + +def _matched_ids(backend: TantivyBackend, query: str) -> set[int]: + return set(backend.search_ids(query, user=None)) + + +def _index(backend: TantivyBackend, **kwargs: object) -> Document: + doc = Document.objects.create(**kwargs) + backend.add_or_update(doc) + return doc + + +class TestUnfieldedDashBecomesARequiredTerm: + @pytest.fixture + def docs(self, backend: TantivyBackend) -> dict[str, int]: + return { + "with_taxes": _index( + backend, + title="Invoice one", + content="invoice taxes included", + checksum="dash-unfielded-with", + ).pk, + "without_taxes": _index( + backend, + title="Invoice two", + content="invoice ordinary contents", + checksum="dash-unfielded-without", + ).pk, + } + + def test_dash_prefixed_term_matches_only_the_document_containing_it( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + # If this were real negation, it would match "without_taxes" (the + # document that does NOT contain "taxes"). It instead matches + # "with_taxes" -- the document that DOES. + assert _matched_ids(backend, "invoice -taxes") == {docs["with_taxes"]} + + def test_bare_dash_prefixed_term_alone_is_a_positive_search( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + assert _matched_ids(backend, "-taxes") == {docs["with_taxes"]} + + +class TestFieldedDashDropsTheNegation: + @pytest.fixture + def docs(self, backend: TantivyBackend) -> dict[str, int]: + return { + "alpha": _index( + backend, + title="Alpha Title", + content="alpha body", + checksum="dash-fielded-alpha", + ).pk, + "beta": _index( + backend, + title="Beta Title", + content="beta body", + checksum="dash-fielded-beta", + ).pk, + } + + def test_dash_fielded_term_matches_the_field_value_it_names( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + # Real negation would exclude the "beta" document. Dropped + # negation instead matches it, identically to "title:beta". + assert _matched_ids(backend, "-title:beta") == {docs["beta"]} + assert _matched_ids(backend, "title:beta") == _matched_ids( + backend, + "-title:beta", + ) + + def test_dash_fielded_term_conjoined_still_requires_both_sides( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + # Discriminating shape: if the dash-fielded clause were dropped + # from the query entirely (rather than kept as a positive + # requirement), this would match "alpha" alone. Because the + # dropped negation still leaves an ordinary AND-ed requirement + # behind, and no document has both titles, nothing matches. + assert _matched_ids(backend, "title:alpha -title:beta") == set() diff --git a/src/documents/tests/search/test_date_keyword_timezone.py b/src/documents/tests/search/test_date_keyword_timezone.py new file mode 100644 index 000000000..243fce260 --- /dev/null +++ b/src/documents/tests/search/test_date_keyword_timezone.py @@ -0,0 +1,83 @@ +"""Date keyword phrases (``today``, etc.) resolved in a non-UTC timezone, +end to end. + +paperless's own ``tz=get_current_timezone()`` plumbing +(``TantivyBackend._parse_query``) is exercised elsewhere only for +relative *ranges* (``added:[-1 week to now]``, in +documents/tests/test_api_search.py). This covers a date *keyword* +(``today``), whose day boundary depends on the active timezone the same +way but goes through whoosh-compat's DateParserPlugin resolution instead +of an explicit range. + +Discriminating shape: frozen at 2026-06-15T02:00 UTC, which is +2026-06-14T22:00 in America/New_York -- still "today" (06-14) there, but +already "today" (06-15) in UTC. Two documents pin both directions of the +mistake a hardcoded-UTC bug would make: + +- ``in_ny_today`` (added 2026-06-14T20:00 UTC = 2026-06-14T16:00 NY) is + inside New York's "today" window and outside a naive UTC-calendar-day + window. A ``tz``-ignoring bug would miss it. +- ``in_utc_calendar_day_only`` (added 2026-06-15T10:00 UTC = + 2026-06-15T06:00 NY) is inside a naive UTC-calendar-day window but + outside New York's actual "today" window. A ``tz``-ignoring bug would + wrongly match it. +""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +import pytest +import time_machine + +from documents.models import Document + +if TYPE_CHECKING: + from pytest_django.fixtures import SettingsWrapper + + from documents.search._backend import TantivyBackend + +pytestmark = [pytest.mark.search, pytest.mark.django_db] + +FROZEN_NOW = datetime(2026, 6, 15, 2, 0, tzinfo=UTC) + + +def _matched_ids(backend: TantivyBackend, query: str) -> set[int]: + return set(backend.search_ids(query, user=None)) + + +def _index(backend: TantivyBackend, **kwargs: object) -> Document: + doc = Document.objects.create(**kwargs) + backend.add_or_update(doc) + return doc + + +class TestDateKeywordUsesTheActiveTimezone: + def test_today_matches_the_new_york_calendar_day_not_the_utc_one( + self, + backend: TantivyBackend, + settings: SettingsWrapper, + ) -> None: + settings.TIME_ZONE = "America/New_York" + with time_machine.travel(FROZEN_NOW, tick=False): + in_ny_today = _index( + backend, + title="NY today", + content="x", + checksum="tz-keyword-ny-today", + added=datetime(2026, 6, 14, 20, 0, tzinfo=UTC), + ) + # Not captured: the exact-set assertion below already proves + # this document (inside a naive UTC-calendar-day window, but + # outside New York's actual "today") does not match. + _index( + backend, + title="UTC calendar day only", + content="x", + checksum="tz-keyword-utc-calendar-day-only", + added=datetime(2026, 6, 15, 10, 0, tzinfo=UTC), + ) + + assert _matched_ids(backend, "added:today") == {in_ny_today.pk} diff --git a/src/documents/tests/search/test_default_search_fields_guard.py b/src/documents/tests/search/test_default_search_fields_guard.py new file mode 100644 index 000000000..ae97018e9 --- /dev/null +++ b/src/documents/tests/search/test_default_search_fields_guard.py @@ -0,0 +1,20 @@ +"""``_DEFAULT_SEARCH_FIELDS`` must stay a subset of the registered public +field names. + +Nothing enforced this before: a rename in PUBLIC_FIELDS not mirrored in +``_DEFAULT_SEARCH_FIELDS`` (documents/search/_query.py) would 400 every +unfielded search at request time, since ``index.parse_query`` and the +fuzzy/CJK clause builders are handed a field name the schema no longer +has. +""" + +from __future__ import annotations + +from documents.search._fields import PUBLIC_FIELDS +from documents.search._query import _DEFAULT_SEARCH_FIELDS + + +class TestDefaultSearchFieldsAreRegistered: + def test_every_default_search_field_is_a_public_field(self) -> None: + public_field_names = {f.name for f in PUBLIC_FIELDS} + assert set(_DEFAULT_SEARCH_FIELDS) <= public_field_names diff --git a/src/documents/tests/search/test_json_subpath_completeness.py b/src/documents/tests/search/test_json_subpath_completeness.py new file mode 100644 index 000000000..66e3335b5 --- /dev/null +++ b/src/documents/tests/search/test_json_subpath_completeness.py @@ -0,0 +1,83 @@ +"""Every declared JSON subpath must actually be written to the index. + +PUBLIC_FIELDS declares each JSON field's subpaths (e.g. ``notes`` -> +{"user", "note"}), but nothing coupled that declaration to what +``_backend.py``'s document builder actually writes into the JSON blob at +index time. A subpath declared but never written would be +queryable-but-always-empty -- syntactically valid, silently matching +nothing -- with no test failure anywhere. + +This indexes one real document carrying values for every JSON field +(a Note, a CustomFieldInstance) and inspects the document's own stored +JSON payload, rather than running field-specific queries: that way a +future JSON field's subpaths are covered automatically, without a new +per-subpath query having to be added by hand each time. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tantivy +from django.contrib.auth.models import User +from whoosh_compat import FieldKind + +from documents.models import CustomField +from documents.models import CustomFieldInstance +from documents.models import Document +from documents.models import Note +from documents.search._fields import PUBLIC_FIELDS + +if TYPE_CHECKING: + from documents.search._backend import TantivyBackend + +pytestmark = [pytest.mark.search, pytest.mark.django_db] + + +class TestJsonSubpathsAreWrittenAtIndexTime: + def test_every_declared_json_subpath_appears_in_the_stored_document( + self, + backend: TantivyBackend, + ) -> None: + user = User.objects.create_user(username="completeness-user") + field = CustomField.objects.create( + name="Completeness Field", + data_type=CustomField.FieldDataType.STRING, + ) + doc = Document.objects.create( + title="Completeness doc", + content="x", + checksum="json-subpath-completeness", + ) + Note.objects.create(document=doc, user=user, note="a note") + CustomFieldInstance.objects.create( + document=doc, + field=field, + value_text="a value", + ) + backend.add_or_update(doc) + + index = backend._index + searcher = index.searcher() + hits = searcher.search( + tantivy.Query.term_query(index.schema, "id", doc.pk), + limit=1, + ).hits + assert hits, "the document was not indexed" + stored = searcher.doc(hits[0][1]).to_dict() + + json_fields = [f for f in PUBLIC_FIELDS if f.kind is FieldKind.JSON] + assert json_fields, "no JSON fields declared - fixture is stale" + for field_spec in json_fields: + stored_values = stored.get(field_spec.name) + assert stored_values, ( + f"{field_spec.name} was not written to the index at all" + ) + written_keys = stored_values[0].keys() + for subpath in field_spec.subpaths: + assert subpath in written_keys, ( + f"{field_spec.name}.{subpath} is declared in PUBLIC_FIELDS " + "but _backend.py's document builder never writes it - it " + "would be queryable but always empty" + ) diff --git a/src/documents/tests/search/test_reversed_date_ranges.py b/src/documents/tests/search/test_reversed_date_ranges.py new file mode 100644 index 000000000..c0f638a70 --- /dev/null +++ b/src/documents/tests/search/test_reversed_date_ranges.py @@ -0,0 +1,143 @@ +"""Reversed date ranges: an internal inconsistency between relative and +absolute bounds, pinned exactly as measured rather than "fixed" here. + +Measured end to end (FROZEN_NOW = 2026-06-15T12:00:00Z): + + added:[now-1h to now+1h] -> 2h window, correct order + added:[now+1h to now-1h] -> ~22h window, DAY-BUMPED, not swapped + added:[2020-01-01 to 2019-01-01] -> 366 days, SWAPPED to the forward order + added:[2019-01-01 to 2020-01-01] -> 366 days (same result either way) + +Absolute reversed ranges swap their bounds back into order, matching +whoosh's own behavior. Relative (``now±``) reversed ranges do not swap -- +whoosh-compat's date grammar instead adds a day to the upper bound, +producing a much wider window than either the forward or a swapped +reading would give. This is a library-level inconsistency between the two +range kinds, not an application-level rewrite paperless performs (there is +no reversed-range handling in documents/search/_query.py), so it is not +"fixed" here: fixing it belongs in whoosh-compat's date grammar, not in a +pre-parse rewrite on this side (see also the CJK/pre-parse-rewrites +docstrings elsewhere in this suite for the same discipline). Pinned as a +known inconsistency and a library follow-up. When whoosh-compat's date +grammar is fixed to swap consistently, the relative case's test below +inverts -- that inversion is the intended, visible signal. +""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +import pytest +import time_machine + +from documents.models import Document + +if TYPE_CHECKING: + from documents.search._backend import TantivyBackend + +pytestmark = [pytest.mark.search, pytest.mark.django_db] + +FROZEN_NOW = datetime(2026, 6, 15, 12, 0, tzinfo=UTC) + + +def _matched_ids(backend: TantivyBackend, query: str) -> set[int]: + return set(backend.search_ids(query, user=None)) + + +def _index(backend: TantivyBackend, **kwargs: object) -> Document: + doc = Document.objects.create(**kwargs) + backend.add_or_update(doc) + return doc + + +class TestRelativeReversedRangeDayBumpsInsteadOfSwapping: + @pytest.fixture + def docs(self, backend: TantivyBackend) -> dict[str, int]: + with time_machine.travel(FROZEN_NOW, tick=False): + return { + # Inside the correct (forward) 2h window [11:00, 13:00], + # outside the day-bumped window [13:00, next-day 11:00). + "in_forward_window": _index( + backend, + title="Forward window doc", + content="x", + checksum="reversed-relative-forward", + added=FROZEN_NOW, + ).pk, + # Outside the correct 2h window, inside the day-bumped + # window the reversed query actually produces. + "in_daybumped_window": _index( + backend, + title="Day-bumped window doc", + content="x", + checksum="reversed-relative-daybumped", + added=datetime(2026, 6, 16, 8, 0, tzinfo=UTC), + ).pk, + } + + def test_forward_range_matches_only_the_two_hour_window( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + with time_machine.travel(FROZEN_NOW, tick=False): + assert _matched_ids(backend, "added:[now-1h to now+1h]") == { + docs["in_forward_window"], + } + + def test_reversed_range_day_bumps_rather_than_swapping( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + # If this swapped like the absolute case below, it would match + # "in_forward_window" (the same set as the forward query). It + # instead matches only the day-bumped document -- the documented + # library inconsistency. + with time_machine.travel(FROZEN_NOW, tick=False): + assert _matched_ids(backend, "added:[now+1h to now-1h]") == { + docs["in_daybumped_window"], + } + + +class TestAbsoluteReversedRangeSwaps: + @pytest.fixture + def docs(self, backend: TantivyBackend) -> dict[str, int]: + return { + "in_range": _index( + backend, + title="In-range doc", + content="x", + checksum="reversed-absolute-in-range", + added=datetime(2019, 6, 1, tzinfo=UTC), + ).pk, + "out_of_range": _index( + backend, + title="Out-of-range doc", + content="x", + checksum="reversed-absolute-out-of-range", + added=datetime(2018, 6, 1, tzinfo=UTC), + ).pk, + } + + def test_forward_range_matches_the_in_range_document( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + assert _matched_ids( + backend, + "added:[2019-01-01 to 2020-01-01]", + ) == {docs["in_range"]} + + def test_reversed_range_swaps_to_match_the_same_document( + self, + backend: TantivyBackend, + docs: dict[str, int], + ) -> None: + assert _matched_ids( + backend, + "added:[2020-01-01 to 2019-01-01]", + ) == {docs["in_range"]} diff --git a/src/documents/tests/search/test_schema.py b/src/documents/tests/search/test_schema.py index 7ab259a73..1d5a51bf3 100644 --- a/src/documents/tests/search/test_schema.py +++ b/src/documents/tests/search/test_schema.py @@ -11,6 +11,7 @@ import tantivy from documents.search._fields import PUBLIC_FIELDS from documents.search._schema import SCHEMA_VERSION from documents.search._schema import build_schema +from documents.search._schema import field_descriptors from documents.search._schema import needs_rebuild from documents.search._schema import schema_fingerprint from documents.search._tokenizer import register_tokenizers @@ -145,12 +146,16 @@ class TestFastFlagAgreement: # 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. - schema_fast = { - name: bool(field["options"].get("fast", False)) - for name, field in _schema_fields(build_schema()).items() - } + # + # field_descriptors() (not tantivy-py's __reduce__() pickling + # internals) is used as the probe here: it is exactly the input + # build_schema()'s SchemaBuilder consumes for the `fast` kwarg on + # every field kind, so it pins the same agreement without depending + # on a private pickled representation surviving a tantivy-py + # upgrade. + descriptor_fast = {d.name: d.fast for d in field_descriptors()} for public_field in PUBLIC_FIELDS: - assert schema_fast[public_field.name] == public_field.fast, ( - f"{public_field.name}: PUBLIC_FIELDS says fast={public_field.fast} but the" - f" built schema says fast={schema_fast[public_field.name]}" + assert descriptor_fast[public_field.name] == public_field.fast, ( + f"{public_field.name}: PUBLIC_FIELDS says fast={public_field.fast} but" + f" field_descriptors() says fast={descriptor_fast[public_field.name]}" ) diff --git a/src/documents/tests/search/test_whoosh_unit_abbreviations.py b/src/documents/tests/search/test_whoosh_unit_abbreviations.py new file mode 100644 index 000000000..f599c7528 --- /dev/null +++ b/src/documents/tests/search/test_whoosh_unit_abbreviations.py @@ -0,0 +1,142 @@ +"""Whoosh-style relative date unit abbreviations: yrs/mos/wks/hrs/mins/secs. + +Zero assertions existed anywhere in this tree before this file -- the old +v2-era ``TestWhooshUnitAbbreviations`` is gone, and whoosh-compat's own +test corpus only carries these spellings as allowlisted (assertion- +inverted) lines, i.e. lines it knows do not fully match its own grammar's +documented behavior yet accepts anyway. This is the exact class of +regression #13482 was: a spelling silently stops parsing (or silently +stops matching) with nothing anywhere to notice. + +Each abbreviation produces a relative, zero-width instant +(``now - N`` .. same instant), not a span -- pinned separately in +the date-keyword tests. What matters here is that each of the six +spellings resolves to the *correct* instant, checked by indexing one +document at exactly that instant per unit: a wrong offset (an "hrs" typo +that resolves as minutes, say) lands on nothing, or on a sibling +document's instant, rather than passing by accident. +""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +import pytest +import time_machine + +from documents.models import Document + +if TYPE_CHECKING: + from documents.search._backend import TantivyBackend + +pytestmark = [pytest.mark.search, pytest.mark.django_db] + +FROZEN_NOW = datetime(2026, 6, 15, 12, 0, tzinfo=UTC) + + +def _matched_ids(backend: TantivyBackend, query: str) -> set[int]: + return set(backend.search_ids(query, user=None)) + + +def _index(backend: TantivyBackend, **kwargs: object) -> Document: + doc = Document.objects.create(**kwargs) + backend.add_or_update(doc) + return doc + + +@pytest.fixture +def unit_documents(backend: TantivyBackend) -> dict[str, int]: + """One document at the exact instant each abbreviation should resolve + to, all indexed together so a wrong offset lands on the wrong (or no) + document rather than passing coincidentally.""" + with time_machine.travel(FROZEN_NOW, tick=False): + return { + "yrs": _index( + backend, + title="Years doc", + content="x", + checksum="unit-abbrev-yrs", + added=datetime(2024, 6, 15, 12, 0, tzinfo=UTC), + ).pk, + "mos": _index( + backend, + title="Months doc", + content="x", + checksum="unit-abbrev-mos", + added=datetime(2026, 3, 15, 12, 0, tzinfo=UTC), + ).pk, + "wks": _index( + backend, + title="Weeks doc", + content="x", + checksum="unit-abbrev-wks", + added=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + ).pk, + "hrs": _index( + backend, + title="Hours doc", + content="x", + checksum="unit-abbrev-hrs", + added=datetime(2026, 6, 15, 7, 0, tzinfo=UTC), + ).pk, + "mins": _index( + backend, + title="Minutes doc", + content="x", + checksum="unit-abbrev-mins", + added=datetime(2026, 6, 15, 11, 50, tzinfo=UTC), + ).pk, + "secs": _index( + backend, + title="Seconds doc", + content="x", + checksum="unit-abbrev-secs", + added=datetime(2026, 6, 15, 11, 59, 30, tzinfo=UTC), + ).pk, + } + + +class TestUnitAbbreviationsResolveToTheCorrectInstant: + @pytest.mark.parametrize( + ("query", "label"), + [ + pytest.param('added:"-2yrs"', "yrs", id="years"), + pytest.param('added:"-3mos"', "mos", id="months"), + pytest.param('added:"-2wks"', "wks", id="weeks"), + pytest.param('added:"-5hrs"', "hrs", id="hours"), + pytest.param('added:"-10mins"', "mins", id="minutes"), + pytest.param('added:"-30secs"', "secs", id="seconds"), + ], + ) + def test_quoted_abbreviation_matches_only_its_own_instant( + self, + backend: TantivyBackend, + unit_documents: dict[str, int], + query: str, + label: str, + ) -> None: + with time_machine.travel(FROZEN_NOW, tick=False): + assert _matched_ids(backend, query) == {unit_documents[label]} + + @pytest.mark.parametrize( + ("query", "label"), + [ + pytest.param("added:-2yrs", "yrs", id="years"), + pytest.param("added:-3mos", "mos", id="months"), + pytest.param("added:-2wks", "wks", id="weeks"), + pytest.param("added:-5hrs", "hrs", id="hours"), + pytest.param("added:-10mins", "mins", id="minutes"), + pytest.param("added:-30secs", "secs", id="seconds"), + ], + ) + def test_bare_unquoted_abbreviation_matches_only_its_own_instant( + self, + backend: TantivyBackend, + unit_documents: dict[str, int], + query: str, + label: str, + ) -> None: + with time_machine.travel(FROZEN_NOW, tick=False): + assert _matched_ids(backend, query) == {unit_documents[label]} diff --git a/src/documents/tests/test_api_search_unterminated_date_range.py b/src/documents/tests/test_api_search_unterminated_date_range.py new file mode 100644 index 000000000..af1eafc7f --- /dev/null +++ b/src/documents/tests/test_api_search_unterminated_date_range.py @@ -0,0 +1,67 @@ +"""An unterminated ``[`` date range bracket at the API level. + +``created:[2020`` (with or without a dangling ``to ``) now raises +BAD_DATE and the search endpoint returns HTTP 400, where it used to parse +past the missing ``]`` and silently pass the malformed range through. +A 400 is correct: malformed input should fail loudly rather than silently +matching an unintended query. Pinned at the API level -- the layer a user +or client actually sees -- rather than only against the parser directly. + +The properly closed decoy proves the bracket is what matters, not +whoosh-compat's date grammar generally: ``created:[2020 to 2021]`` parses +and searches cleanly. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from rest_framework import status + +from documents.tests.factories import DocumentFactory + +if TYPE_CHECKING: + from rest_framework.test import APIClient + + from documents.models import Document + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")] + + +@pytest.fixture +def indexed_document() -> Document: + from documents.search import get_backend + + doc = DocumentFactory.create(title="quarterly invoice", content="acme corp") + get_backend().add_or_update(doc) + return doc + + +class TestUnterminatedBracketReturnsA400: + @pytest.mark.parametrize( + "query", + [ + pytest.param("created:[2020", id="missing_upper_bound_and_bracket"), + pytest.param("created:[2020 to 2021", id="missing_closing_bracket"), + ], + ) + def test_unterminated_bracket_is_a_400( + self, + admin_client: APIClient, + indexed_document: Document, + query: str, + ) -> None: + response = admin_client.get(f"/api/documents/?query={query}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "created" in str(response.data["query"]) + + def test_properly_closed_bracket_still_searches_cleanly( + self, + admin_client: APIClient, + indexed_document: Document, + ) -> None: + response = admin_client.get( + "/api/documents/?query=created:[2020 to 2021]", + ) + assert response.status_code == status.HTTP_200_OK