mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-11 20:28:01 +00:00
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""Diagnostics route by Cause, and user-facing messages are host-owned.
|
|
|
|
whoosh-compat documents ``Diagnostic.message`` as developer output with no
|
|
stability guarantee, so it must never reach an HTTP response body.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC
|
|
|
|
import pytest
|
|
import tantivy
|
|
from whoosh_compat.errors import Diagnostic
|
|
from whoosh_compat.errors import DiagnosticKind
|
|
from whoosh_compat.errors import QueryError
|
|
from whoosh_compat.errors import cause_for
|
|
from whoosh_compat.fields import FieldKind
|
|
from whoosh_compat.fields import FieldRef
|
|
|
|
from documents.search._errors import SearchQueryError
|
|
from documents.search._query import _map_emit_error
|
|
from documents.search._query import _single_diagnostic_to_error
|
|
from documents.search._query import parse_user_query
|
|
from documents.search._schema import build_schema
|
|
from documents.search._tokenizer import register_tokenizers
|
|
|
|
pytestmark = pytest.mark.search
|
|
|
|
_LIBRARY_PROSE = "INTERNAL LIBRARY WORDING WITH raw tantivy detail"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def query_index() -> tantivy.Index:
|
|
"""An in-memory, unstemmed index; these tests only parse, never index."""
|
|
idx = tantivy.Index(build_schema(), path=None)
|
|
register_tokenizers(idx, "")
|
|
return idx
|
|
|
|
|
|
def _diagnostic(
|
|
kind: DiagnosticKind,
|
|
*,
|
|
field: FieldRef | None = FieldRef("title"),
|
|
field_kind: FieldKind | None = FieldKind.TEXT,
|
|
) -> Diagnostic:
|
|
"""A Diagnostic shaped like the emitter's, with the library's own
|
|
kind -> cause mapping rather than a hand-picked cause."""
|
|
return Diagnostic(
|
|
kind=kind,
|
|
cause=cause_for(kind),
|
|
message=_LIBRARY_PROSE,
|
|
field=field,
|
|
field_kind=field_kind,
|
|
)
|
|
|
|
|
|
class TestEmitErrorRouting:
|
|
"""Every Cause gets a distinguishable treatment, not just "a 400"."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"kind",
|
|
[
|
|
DiagnosticKind.BACKEND_REJECTED,
|
|
DiagnosticKind.AST_INVALID_SHAPE,
|
|
DiagnosticKind.AST_UNKNOWN_FIELD,
|
|
],
|
|
)
|
|
def test_internal_cause_is_not_converted(self, kind: DiagnosticKind) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A QueryError wrapping a Diagnostic whose Cause is INTERNAL
|
|
(BACKEND_REJECTED/AST_INVALID_SHAPE/AST_UNKNOWN_FIELD)
|
|
WHEN:
|
|
- _map_emit_error processes it
|
|
THEN:
|
|
- The original QueryError propagates unchanged, so it surfaces
|
|
as a 500 monitoring can see, never a 400 blaming the user
|
|
"""
|
|
error = QueryError(_diagnostic(kind))
|
|
with pytest.raises(QueryError) as excinfo:
|
|
_map_emit_error(error)
|
|
assert excinfo.value is error
|
|
|
|
def test_misconfigured_cause_is_logged_and_becomes_a_400(
|
|
self,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A QueryError for SCHEMA_FIELD_MISSING naming field "asn"
|
|
WHEN:
|
|
- _map_emit_error processes it
|
|
THEN:
|
|
- It becomes a SearchQueryError, and exactly one ERROR log
|
|
record is emitted naming the field and the diagnostic kind
|
|
"""
|
|
kind = DiagnosticKind.SCHEMA_FIELD_MISSING
|
|
with caplog.at_level(logging.ERROR, logger="paperless.search"):
|
|
error = _map_emit_error(
|
|
QueryError(_diagnostic(kind, field=FieldRef("asn"))),
|
|
)
|
|
assert isinstance(error, SearchQueryError)
|
|
errors = [r for r in caplog.records if r.levelno == logging.ERROR]
|
|
assert len(errors) == 1
|
|
assert "asn" in errors[0].getMessage()
|
|
assert kind.name in errors[0].getMessage()
|
|
|
|
@pytest.mark.parametrize(
|
|
"kind",
|
|
[
|
|
DiagnosticKind.TEXT_RANGE,
|
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
|
],
|
|
)
|
|
def test_unsupported_cause_is_a_400_with_no_operator_log(
|
|
self,
|
|
kind: DiagnosticKind,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A QueryError for a query tantivy cannot run
|
|
(TEXT_RANGE/PATTERN_TOO_COMPLEX/EXISTS_REQUIRES_FAST)
|
|
WHEN:
|
|
- _map_emit_error processes it
|
|
THEN:
|
|
- It becomes a SearchQueryError with no log record at WARNING
|
|
or above; a query tantivy cannot run is the user's to fix,
|
|
not an operator alert. EXISTS_REQUIRES_FAST is nominally
|
|
MISCONFIGURED but belongs here: it is decided from the
|
|
registry's own FieldSpec, so it never reports a disagreement
|
|
anyone could resolve
|
|
"""
|
|
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
|
error = _map_emit_error(QueryError(_diagnostic(kind)))
|
|
assert isinstance(error, SearchQueryError)
|
|
assert caplog.records == []
|
|
|
|
@pytest.mark.parametrize(
|
|
"kind",
|
|
[
|
|
DiagnosticKind.TEXT_RANGE,
|
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
|
DiagnosticKind.SCHEMA_FIELD_MISSING,
|
|
],
|
|
)
|
|
def test_user_facing_message_never_echoes_library_prose(
|
|
self,
|
|
kind: DiagnosticKind,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A QueryError carrying whoosh-compat's own developer-facing
|
|
message text
|
|
WHEN:
|
|
- _map_emit_error processes it
|
|
THEN:
|
|
- The resulting error's string never contains that library
|
|
prose
|
|
"""
|
|
error = _map_emit_error(QueryError(_diagnostic(kind)))
|
|
assert _LIBRARY_PROSE not in str(error)
|
|
|
|
@pytest.mark.parametrize(
|
|
"kind",
|
|
[
|
|
DiagnosticKind.TEXT_RANGE,
|
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
|
DiagnosticKind.SCHEMA_FIELD_MISSING,
|
|
],
|
|
)
|
|
def test_user_facing_message_names_the_field(
|
|
self,
|
|
kind: DiagnosticKind,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A QueryError for a JSON subpath field (custom_fields.value)
|
|
WHEN:
|
|
- _map_emit_error processes it
|
|
THEN:
|
|
- The resulting error names the field using its canonical
|
|
dotted form, including the subpath (FieldRef.__str__ yields
|
|
this dotted name, so every user-reachable emit kind can name
|
|
it)
|
|
"""
|
|
diagnostic = _diagnostic(
|
|
kind,
|
|
field=FieldRef("custom_fields", "value"),
|
|
field_kind=FieldKind.JSON,
|
|
)
|
|
error = _map_emit_error(QueryError(diagnostic))
|
|
assert "custom_fields.value" in str(error)
|
|
|
|
|
|
class TestParseDiagnosticMessages:
|
|
"""Parse-time diagnostics are host-worded too, off field_kind."""
|
|
|
|
def test_too_deep_is_a_400_without_library_prose(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A parse-time Diagnostic for TOO_DEEP with no field
|
|
WHEN:
|
|
- _single_diagnostic_to_error processes it
|
|
THEN:
|
|
- It becomes a SearchQueryError with no library prose in its
|
|
message
|
|
"""
|
|
error = _single_diagnostic_to_error(
|
|
_diagnostic(DiagnosticKind.TOO_DEEP, field=None, field_kind=None),
|
|
)
|
|
assert isinstance(error, SearchQueryError)
|
|
assert _LIBRARY_PROSE not in str(error)
|
|
|
|
@pytest.mark.parametrize(
|
|
("kind", "field_kind"),
|
|
[
|
|
(DiagnosticKind.PATTERN_ON_NUMERIC, FieldKind.U64),
|
|
(DiagnosticKind.PATTERN_ON_BOOLEAN_EXISTS, FieldKind.BOOLEAN_EXISTS),
|
|
(DiagnosticKind.PATTERN_ON_SUBPATH, FieldKind.JSON),
|
|
],
|
|
)
|
|
def test_pattern_on_kinds_name_the_field_and_its_kind(
|
|
self,
|
|
kind: DiagnosticKind,
|
|
field_kind: FieldKind,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A parse-time Diagnostic for a pattern used against a kind
|
|
that cannot take one
|
|
(PATTERN_ON_NUMERIC/PATTERN_ON_BOOLEAN_EXISTS/PATTERN_ON_SUBPATH)
|
|
WHEN:
|
|
- _single_diagnostic_to_error processes it
|
|
THEN:
|
|
- The message names both the field and its kind, with no
|
|
library prose
|
|
"""
|
|
error = _single_diagnostic_to_error(
|
|
_diagnostic(kind, field=FieldRef("asn"), field_kind=field_kind),
|
|
)
|
|
message = str(error)
|
|
assert _LIBRARY_PROSE not in message
|
|
assert "asn" in message
|
|
assert field_kind.name.lower() in message
|
|
|
|
def test_single_char_bracket_range_names_the_field_and_the_value(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A SINGLE_CHAR_BRACKET_RANGE diagnostic for "title" with
|
|
raw_value "200[1-9]"
|
|
WHEN:
|
|
- _single_diagnostic_to_error processes it
|
|
THEN:
|
|
- The resulting SearchQueryError names both the field and the
|
|
offending value, with no library prose
|
|
"""
|
|
diagnostic = Diagnostic(
|
|
kind=DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE,
|
|
cause=cause_for(DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE),
|
|
message=_LIBRARY_PROSE,
|
|
field=FieldRef("title"),
|
|
field_kind=FieldKind.TEXT,
|
|
raw_value="200[1-9]",
|
|
)
|
|
error = _single_diagnostic_to_error(diagnostic)
|
|
message = str(error)
|
|
assert isinstance(error, SearchQueryError)
|
|
assert _LIBRARY_PROSE not in message
|
|
assert "title" in message
|
|
assert "200[1-9]" in message
|
|
|
|
|
|
class TestRealQueriesRouteCorrectly:
|
|
"""The routing table against diagnostics emit() really produces."""
|
|
|
|
def test_text_range_is_a_400_naming_the_field(
|
|
self,
|
|
query_index: tantivy.Index,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A real query index
|
|
WHEN:
|
|
- parse_user_query is called with a text-range query
|
|
("title:[a to b]")
|
|
THEN:
|
|
- It raises SearchQueryError naming "title"
|
|
"""
|
|
with pytest.raises(SearchQueryError) as excinfo:
|
|
parse_user_query(query_index, "title:[a to b]", UTC)
|
|
assert "title" in str(excinfo.value)
|
|
|
|
def test_wildcard_on_a_numeric_field_is_a_400_naming_the_field(
|
|
self,
|
|
query_index: tantivy.Index,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A real query index
|
|
WHEN:
|
|
- parse_user_query is called with a wildcard on a numeric
|
|
field ("asn:12*")
|
|
THEN:
|
|
- It raises SearchQueryError naming "asn"
|
|
"""
|
|
with pytest.raises(SearchQueryError) as excinfo:
|
|
parse_user_query(query_index, "asn:12*", UTC)
|
|
assert "asn" in str(excinfo.value)
|
|
|
|
def test_single_char_bracket_range_is_a_400_naming_field_and_value(
|
|
self,
|
|
query_index: tantivy.Index,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A real query index
|
|
WHEN:
|
|
- parse_user_query is called with "title:200[1-9]"
|
|
THEN:
|
|
- It raises SearchQueryError naming both "title" and
|
|
"200[1-9]"
|
|
"""
|
|
with pytest.raises(SearchQueryError) as excinfo:
|
|
parse_user_query(query_index, "title:200[1-9]", UTC)
|
|
message = str(excinfo.value)
|
|
assert "title" in message
|
|
assert "200[1-9]" in message
|
|
|
|
def test_internal_diagnostic_escapes_as_a_query_error(
|
|
self,
|
|
query_index: tantivy.Index,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- tantivy_emit monkeypatched to raise a QueryError with an
|
|
INTERNAL-cause diagnostic (BACKEND_REJECTED), the one case
|
|
with no query text of its own involved
|
|
WHEN:
|
|
- parse_user_query runs a normal query ("invoice")
|
|
THEN:
|
|
- The QueryError propagates unconverted; emit() reporting a
|
|
defect in itself must not become a user-facing 400
|
|
"""
|
|
import documents.search._query as query_mod
|
|
|
|
def raise_internal(*args: object, **kwargs: object) -> None:
|
|
raise QueryError(_diagnostic(DiagnosticKind.BACKEND_REJECTED))
|
|
|
|
monkeypatch.setattr(query_mod, "tantivy_emit", raise_internal)
|
|
with pytest.raises(QueryError):
|
|
parse_user_query(query_index, "invoice", UTC)
|