Files
cyberyan_linkedin_search/backend/tests/test_search_api.py
T
mobiniqow a464e550c9 feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
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
2026-09-03 08:13:50 +00:00

146 lines
5.5 KiB
Python

"""
End-to-end tests of the search endpoint against the PostgreSQL backend.
They pin down the behaviour the task description asks for: keyword search plus
at least two filters, and a correct contract between frontend and backend.
"""
from __future__ import annotations
import pytest
from django.urls import reverse
pytestmark = pytest.mark.django_db
def search(client, **params): # noqa: ANN001, ANN201
return client.get(reverse("v1:search:search"), params)
def names(response) -> list[str]: # noqa: ANN001
return [hit["full_name"] for hit in response.data["results"]]
def test_empty_query_returns_every_profile(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
response = search(auth_client(analyst))
assert response.status_code == 200
assert response.data["count"] == len(sample_profiles)
assert response.data["backend"] == "postgres"
def test_keyword_matches_skills_and_summary(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
response = search(auth_client(analyst), q="django")
assert names(response) == ["sara python"]
def test_keyword_matches_a_person_name(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
assert names(search(auth_client(analyst), q="maria")) == ["maria frontend"]
def test_keyword_tolerates_a_typo_in_the_name(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
"""Trigram similarity is what makes ``jhon`` still find ``john``."""
assert "john recruiter" in names(search(auth_client(analyst), q="jhon recruiter"))
def test_skill_filter(auth_client, analyst, sample_profiles) -> None: # noqa: ANN001
assert names(search(auth_client(analyst), skills="react")) == ["maria frontend"]
def test_multiple_values_inside_one_filter_are_or_combined(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
response = search(auth_client(analyst), skills="react,django")
assert sorted(names(response)) == ["maria frontend", "sara python"]
def test_different_filters_are_and_combined(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
response = search(auth_client(analyst), skills="react,django", countries="spain")
assert names(response) == ["maria frontend"]
def test_role_and_experience_filters(auth_client, analyst, sample_profiles) -> None: # noqa: ANN001
response = search(auth_client(analyst), roles="engineering", min_experience=5)
assert names(response) == ["sara python"]
def test_repeated_query_parameters_are_supported(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
client = auth_client(analyst)
response = client.get(f"{reverse('v1:search:search')}?skills=react&skills=django")
assert sorted(names(response)) == ["maria frontend", "sara python"]
def test_facets_reflect_the_filtered_result_set(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
response = search(auth_client(analyst), roles="engineering")
countries = {bucket["key"] for bucket in response.data["facets"]["country"]}
assert countries == {"germany", "spain"}
assert response.data["facets"]["skills"]
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"
assert names(search(auth_client(analyst), sort="name"))[0] == "john recruiter"
def test_pagination_metadata(auth_client, analyst, sample_profiles) -> None: # noqa: ANN001
response = search(auth_client(analyst), page_size=2, page=2, sort="name")
assert response.data["page"] == 2
assert response.data["page_size"] == 2
assert response.data["total_pages"] == 2
assert len(response.data["results"]) == 1
def test_highlight_is_returned_for_keyword_searches(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
hit = search(auth_client(analyst), q="django").data["results"][0]
assert "<mark>" in " ".join(hit["highlight"].get("skills", []))
def test_invalid_experience_range_is_rejected(auth_client, analyst) -> None: # noqa: ANN001
response = search(auth_client(analyst), min_experience=10, max_experience=2)
assert response.status_code == 400
assert response.data["error"]["code"] == "validation_error"
def test_page_size_is_capped(auth_client, analyst) -> None: # noqa: ANN001
assert search(auth_client(analyst), page_size=5000).status_code == 400
def test_deep_pagination_is_refused(auth_client, analyst) -> None: # noqa: ANN001
assert search(auth_client(analyst), page=99999, page_size=100).status_code == 400
def test_unknown_filter_values_return_an_empty_page_not_an_error(
auth_client, analyst, sample_profiles
) -> None: # noqa: ANN001
response = search(auth_client(analyst), skills="cobol")
assert response.status_code == 200
assert response.data["count"] == 0
def test_suggest_endpoint(auth_client, analyst, sample_profiles) -> None: # noqa: ANN001
response = auth_client(analyst).get(reverse("v1:search:suggest"), {"q": "mar"})
assert response.status_code == 200
assert "maria frontend" in response.data["suggestions"]
def test_skill_dictionary_endpoint(auth_client, analyst, sample_profiles) -> None: # noqa: ANN001
response = auth_client(analyst).get(reverse("v1:profiles:skills"), {"q": "py"})
assert response.status_code == 200
assert any(item["name"] == "python" for item in response.data)