start project
This commit is contained in:
@@ -11,15 +11,22 @@ 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:
|
||||
clause = _bool(SearchRequest())
|
||||
assert clause["must"] == [{"match_all": {}}]
|
||||
assert clause["filter"] == []
|
||||
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:
|
||||
@@ -32,23 +39,35 @@ def test_keyword_query_uses_fuzzy_multi_match_and_phrase_boost() -> None:
|
||||
assert clause["should"][0]["multi_match"]["type"] == "phrase"
|
||||
|
||||
|
||||
def test_filters_are_terms_clauses_in_filter_context() -> None:
|
||||
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 {"terms": {"skills.keyword": ["python", "django"]}} in clause["filter"]
|
||||
assert {"terms": {"location_country": ["germany"]}} in clause["filter"]
|
||||
assert {"terms": {"seniority_levels": ["senior"]}} in clause["filter"]
|
||||
# Filters must not leak into the scoring clauses.
|
||||
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:
|
||||
clause = _bool(SearchRequest(min_experience=5, max_experience=10))
|
||||
assert {"range": {"years_experience": {"gte": 5, "lte": 10}}} in clause["filter"]
|
||||
filters = _post_filters(SearchRequest(min_experience=5, max_experience=10))
|
||||
assert {"range": {"years_experience": {"gte": 5, "lte": 10}}} in filters
|
||||
|
||||
open_ended = _bool(SearchRequest(min_experience=5))
|
||||
assert {"range": {"years_experience": {"gte": 5}}} in open_ended["filter"]
|
||||
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:
|
||||
@@ -74,3 +93,62 @@ def test_highlight_only_requested_with_a_keyword() -> None:
|
||||
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}}}
|
||||
]
|
||||
|
||||
|
||||
@@ -90,6 +90,50 @@ def test_facets_reflect_the_filtered_result_set(
|
||||
assert response.data["facets"]["skills"]
|
||||
|
||||
|
||||
def test_selecting_a_facet_value_keeps_its_own_sibling_values_visible(
|
||||
auth_client, analyst, sample_profiles
|
||||
) -> None: # noqa: ANN001
|
||||
"""
|
||||
Multi-select facets: ticking "Engineering" under Job family must not make
|
||||
"Human resources" disappear from that same checkbox list - a facet is
|
||||
scoped by every *other* active filter, never by its own. Every other
|
||||
facet (country, in ``test_facets_reflect_the_filtered_result_set`` above)
|
||||
keeps narrowing to the selection as before.
|
||||
"""
|
||||
response = search(auth_client(analyst), roles="engineering")
|
||||
assert sorted(names(response)) == ["maria frontend", "sara python"]
|
||||
|
||||
roles = {bucket["key"]: bucket["count"] for bucket in response.data["facets"]["job_title_role"]}
|
||||
assert roles == {"engineering": 2, "human_resources": 1}
|
||||
|
||||
|
||||
def test_self_exclusion_does_not_ignore_other_active_filters(
|
||||
auth_client, analyst, sample_profiles
|
||||
) -> None: # noqa: ANN001
|
||||
"""
|
||||
Excluding a facet's own filter must not turn into excluding *all*
|
||||
filters: with ``roles=engineering`` AND ``countries=germany`` both active,
|
||||
the job-family facet drops only its own ``roles`` filter and keeps
|
||||
``countries=germany``. "human_resources" (john, based in the United
|
||||
States) correctly stays absent here - not because the facet is narrowed by
|
||||
its own selection, but because no German profile has that role at all.
|
||||
Contrast with ``test_selecting_a_facet_value_keeps_its_own_sibling_values_visible``,
|
||||
where the same "human_resources" sibling *does* reappear once
|
||||
``countries`` is not also constraining the result set.
|
||||
"""
|
||||
response = search(auth_client(analyst), roles="engineering", countries="germany")
|
||||
assert names(response) == ["sara python"]
|
||||
|
||||
roles = {bucket["key"] for bucket in response.data["facets"]["job_title_role"]}
|
||||
assert roles == {"engineering"}
|
||||
|
||||
countries = {bucket["key"] for bucket in response.data["facets"]["country"]}
|
||||
# Scoped by roles=engineering (the other active filter) but not by its
|
||||
# own countries=germany selection, so spain (maria, also engineering)
|
||||
# still shows up.
|
||||
assert countries == {"germany", "spain"}
|
||||
|
||||
|
||||
def test_sorting_by_experience(auth_client, analyst, sample_profiles) -> None: # noqa: ANN001
|
||||
assert names(search(auth_client(analyst), sort="-experience"))[0] == "john recruiter"
|
||||
assert names(search(auth_client(analyst), sort="experience"))[0] == "maria frontend"
|
||||
|
||||
Reference in New Issue
Block a user