"""Query grammar parser (SPEC ยง8.2). Covers every operator, negation, quoting, OR, and garbage input (the parser must never raise).""" from __future__ import annotations import uuid import pytest from app.search.queryparser import KNOWN_KEYS, free_text, parse, to_search_query def _flat(query: str) -> list[dict[str, object]]: return [clause.as_dict() for group in parse(query) for clause in group] def test_plain_free_text() -> None: groups = parse("hello world") assert len(groups) == 2 assert all(len(g) == 1 for g in groups) assert free_text(groups) == "hello world" def test_quoted_phrase_is_one_term() -> None: groups = parse('"hello world" foo') assert groups[0][0].value == "hello world" assert groups[0][0].phrase is True assert groups[1][0].value == "foo" @pytest.mark.parametrize("key", sorted(KNOWN_KEYS)) def test_every_operator_parses_as_filter(key: str) -> None: groups = parse(f"{key}:value") clause = groups[0][0] assert clause.type == "filter" assert clause.key == key assert clause.value == "value" def test_filter_with_quoted_value() -> None: clause = parse('from:"John Doe"')[0][0] assert clause.type == "filter" assert clause.key == "from" assert clause.value == "John Doe" def test_negation_of_term_and_filter() -> None: clauses = _flat("-spam -from:boss@x.com") assert clauses[0] == { "type": "text", "key": None, "value": "spam", "negate": True, "phrase": False, } assert clauses[1]["type"] == "filter" assert clauses[1]["negate"] is True assert clauses[1]["key"] == "from" def test_or_groups_adjacent_clauses() -> None: groups = parse("cat OR dog fish") # (cat OR dog) AND fish assert len(groups) == 2 assert [c.value for c in groups[0]] == ["cat", "dog"] assert [c.value for c in groups[1]] == ["fish"] def test_or_mixing_text_and_filter() -> None: groups = parse("is:unread OR is:starred") assert len(groups) == 1 assert [c.value for c in groups[0]] == ["unread", "starred"] def test_unknown_key_is_free_text() -> None: clause = parse("bogus:value")[0][0] assert clause.type == "text" assert clause.value == "bogus:value" def test_case_insensitive_keys_lowercased() -> None: clause = parse("FROM:alice")[0][0] assert clause.type == "filter" assert clause.key == "from" @pytest.mark.parametrize( "garbage", ['unbalanced "quote', "'", '"', "-", ":", "from:", " ", "", "OR OR OR", "a OR"], ) def test_garbage_never_raises(garbage: str) -> None: # Must not raise; returns some (possibly empty) parse. result = parse(garbage) assert isinstance(result, list) def test_to_search_query_sets_user_id_and_text() -> None: uid = uuid.uuid4() sq = to_search_query("invoice from:alice", uid) assert sq.user_id == uid assert sq.text == "invoice" assert "groups" in sq.filters def test_dash_only_token_is_text() -> None: # A lone "-" must not be treated as negation of an empty term. clause = parse("-")[0][0] assert clause.value == "-" assert clause.negate is False