""" Unit tests for the Elasticsearch query builder. ``build_query`` is a pure function, so the ranking and filtering rules can be asserted without a running cluster - the fastest possible feedback loop for the part of the system that is easiest to get subtly wrong. """ from __future__ import annotations from apps.search.backends.elastic import build_query from apps.search.types import SORT_EXPERIENCE_DESC, SORT_NAME, SearchRequest FACET_NAMES = ("skills", "job_title_role", "seniority", "country", "industry", "company") def _bool(request: SearchRequest) -> dict: return build_query(request)["query"]["bool"] def _post_filters(request: SearchRequest) -> list[dict]: body = build_query(request) return body.get("post_filter", {}).get("bool", {}).get("filter", []) def test_empty_query_matches_everything() -> None: body = build_query(SearchRequest()) assert body["query"]["bool"]["must"] == [{"match_all": {}}] assert "post_filter" not in body def test_keyword_query_uses_fuzzy_multi_match_and_phrase_boost() -> None: clause = _bool(SearchRequest(q="python developer")) multi_match = clause["must"][0]["multi_match"] assert multi_match["query"] == "python developer" assert multi_match["fuzziness"] == "AUTO" assert any(field.startswith("full_name^") for field in multi_match["fields"]) # The phrase clause only boosts; it must never be required. assert clause["should"][0]["multi_match"]["type"] == "phrase" def test_filters_do_not_narrow_the_scored_query() -> None: """ Filters must live in ``post_filter``, never in ``query.bool`` - that is what lets a facet's own aggregation stay unaffected by its own selection. See ``test_a_facets_own_selection_does_not_narrow_its_own_aggregation`` for the behaviour this enables. """ clause = _bool( SearchRequest(skills=("python", "django"), countries=("germany",), seniority=("senior",)) ) assert clause["must"] == [{"match_all": {}}] assert "filter" not in clause def test_filters_are_terms_clauses_in_the_post_filter() -> None: filters = _post_filters( SearchRequest(skills=("python", "django"), countries=("germany",), seniority=("senior",)) ) assert {"terms": {"skills.keyword": ["python", "django"]}} in filters assert {"terms": {"location_country": ["germany"]}} in filters assert {"terms": {"seniority_levels": ["senior"]}} in filters def test_experience_range_is_translated_to_a_range_filter() -> None: filters = _post_filters(SearchRequest(min_experience=5, max_experience=10)) assert {"range": {"years_experience": {"gte": 5, "lte": 10}}} in filters open_ended = _post_filters(SearchRequest(min_experience=5)) assert {"range": {"years_experience": {"gte": 5}}} in open_ended def test_pagination_maps_to_from_and_size() -> None: body = build_query(SearchRequest(page=3, page_size=25)) assert body["from"] == 50 assert body["size"] == 25 assert body["track_total_hits"] is True def test_sort_options() -> None: assert build_query(SearchRequest(sort=SORT_NAME))["sort"][0] == {"full_name.keyword": "asc"} assert build_query(SearchRequest(sort=SORT_EXPERIENCE_DESC))["sort"][0] == { "years_experience": {"order": "desc", "missing": "_last"} } assert build_query(SearchRequest())["sort"][0] == "_score" def test_highlight_only_requested_with_a_keyword() -> None: assert "highlight" not in build_query(SearchRequest()) assert "highlight" in build_query(SearchRequest(q="python")) def test_facets_can_be_switched_off() -> None: assert "aggs" in build_query(SearchRequest()) assert "aggs" not in build_query(SearchRequest(include_facets=False)) # -- Multi-select faceting --------------------------------------------------- # # The bug this section pins down: ticking one value in a facet (e.g. "senior" # under Seniority) must not make the other values in *that same facet* # disappear. Elasticsearch has no built-in notion of "OR within a facet, # ignore this facet's own filter" - it has to be built by hand as one `filter` # aggregation per facet, scoped by every *other* active filter. These tests # exist so a future refactor of ``build_query``/``_build_aggregations`` cannot # silently regress back to the single-facet-collapses-to-one-value bug. def test_a_facets_own_selection_does_not_narrow_its_own_aggregation() -> None: aggs = build_query(SearchRequest(roles=("engineering",)))["aggs"] # `job_title_role` is the facet driven by `roles` - its own filter must be # absent from its own aggregation, so unrelated role buckets still appear. assert aggs["job_title_role"] == { "terms": {"field": "job_title_role", "size": 25, "order": {"_count": "desc"}} } def test_a_facets_selection_does_narrow_every_other_facet() -> None: aggs = build_query(SearchRequest(roles=("engineering",)))["aggs"] for facet in ("skills", "seniority", "country", "industry", "company"): wrapper = aggs[facet] assert wrapper["filter"] == { "bool": {"filter": [{"terms": {"job_title_role": ["engineering"]}}]} } # The terms aggregation itself is nested one level deeper, under its # own name, once it is wrapped in the scoping `filter` aggregation. assert facet in wrapper["aggs"] def test_two_active_facets_each_exclude_only_their_own_filter() -> None: aggs = build_query(SearchRequest(roles=("engineering",), countries=("germany",)))["aggs"] role_filters = aggs["job_title_role"]["filter"]["bool"]["filter"] assert role_filters == [{"terms": {"location_country": ["germany"]}}] country_filters = aggs["country"]["filter"]["bool"]["filter"] assert country_filters == [{"terms": {"job_title_role": ["engineering"]}}] # A facet with no selection of its own is scoped by both. skills_filters = aggs["skills"]["filter"]["bool"]["filter"] assert {"terms": {"job_title_role": ["engineering"]}} in skills_filters assert {"terms": {"location_country": ["germany"]}} in skills_filters def test_experience_range_scopes_every_facet_and_is_never_excluded() -> None: """The years-of-experience range has no facet of its own, so it always applies - including to a facet that shares no other active filter.""" aggs = build_query(SearchRequest(min_experience=5))["aggs"] for facet in FACET_NAMES: wrapper = aggs[facet] assert wrapper["filter"]["bool"]["filter"] == [ {"range": {"years_experience": {"gte": 5}}} ]