a464e550c9
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
77 lines
2.9 KiB
Python
77 lines
2.9 KiB
Python
"""
|
|
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
|
|
|
|
|
|
def _bool(request: SearchRequest) -> dict:
|
|
return build_query(request)["query"]["bool"]
|
|
|
|
|
|
def test_empty_query_matches_everything() -> None:
|
|
clause = _bool(SearchRequest())
|
|
assert clause["must"] == [{"match_all": {}}]
|
|
assert clause["filter"] == []
|
|
|
|
|
|
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_are_terms_clauses_in_filter_context() -> None:
|
|
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": {}}]
|
|
|
|
|
|
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"]
|
|
|
|
open_ended = _bool(SearchRequest(min_experience=5))
|
|
assert {"range": {"years_experience": {"gte": 5}}} in open_ended["filter"]
|
|
|
|
|
|
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))
|