start project
CI / Backend (lint, types, tests) (push) Has been cancelled
CI / Frontend (lint, types, build) (push) Has been cancelled
CI / Docker images build (push) Has been cancelled

This commit is contained in:
2026-09-04 12:52:21 +03:30
parent a464e550c9
commit 212074c5f5
40 changed files with 2804 additions and 164 deletions
+4 -1
View File
@@ -17,6 +17,9 @@ docs/
**/.pytest_cache/ **/.pytest_cache/
**/.mypy_cache/ **/.mypy_cache/
**/.ruff_cache/ **/.ruff_cache/
backend/tests/ # backend/tests/ is intentionally NOT excluded: the `dev` build stage
# needs it in the build context to run the suite in CI/locally. The
# `runtime` stage never copies it in (see backend/Dockerfile), so the
# production image stays test-free regardless.
*.log *.log
*.zip *.zip
+4
View File
@@ -12,6 +12,10 @@ DJANGO_SECRET_KEY=change-me-to-a-64-character-random-string-before-any-real-depl
DJANGO_DEBUG=false DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
DJANGO_ADMIN_URL=admin/ DJANGO_ADMIN_URL=admin/
# Serve /api/docs/, /api/redoc/ and /api/schema/. Handy locally; set to false
# on any public deployment, since they describe every endpoint and payload in
# the service and are reachable without authenticating.
SERVE_API_DOCS=true
ENVIRONMENT=development ENVIRONMENT=development
SERVICE_NAME=linkedin-search-api SERVICE_NAME=linkedin-search-api
SECURE_SSL_REDIRECT=false SECURE_SSL_REDIRECT=false
+11 -2
View File
@@ -1,6 +1,9 @@
# Environment / secrets # Environment / secrets
.env # Glob, not just ".env": local backups (.env.backup-*, .env.prod, .env.bak)
.env.local # hold the same credentials and must never be committed. .env.example is the
# only member of the family that is safe, so it is re-included explicitly.
.env*
!.env.example
*.pem *.pem
*.key *.key
@@ -22,6 +25,12 @@ node_modules/
frontend/dist/ frontend/dist/
npm-debug.log* npm-debug.log*
# Test artefacts
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
frontend/coverage/
# Editors / OS # Editors / OS
.vscode/ .vscode/
.idea/ .idea/
+24 -4
View File
@@ -14,7 +14,11 @@ help: ## Show this help
# --- Lifecycle --------------------------------------------------------------- # --- Lifecycle ---------------------------------------------------------------
.PHONY: env .PHONY: env
env: ## Create .env from the template (never overwrites an existing one) env: ## Create .env from the template (never overwrites an existing one)
@test -f .env || (cp .env.example .env && echo "Created .env - review the secrets before deploying.") @test -f .env || (cp .env.example .env && echo "Created .env" && ./scripts/generate_secrets.sh)
.PHONY: secrets
secrets: ## Replace any remaining change-me placeholders in .env with real secrets
@./scripts/generate_secrets.sh
.PHONY: up .PHONY: up
up: env ## Build and start the whole stack up: env ## Build and start the whole stack
@@ -27,16 +31,32 @@ up: env ## Build and start the whole stack
dev: env ## Start the stack with hot reload (Vite on :5173, runserver on :8000) dev: env ## Start the stack with hot reload (Vite on :5173, runserver on :8000)
$(COMPOSE_DEV) up --build $(COMPOSE_DEV) up --build
.PHONY: prod-volumes
prod-volumes: ## Create the external data volumes the production overlay expects
@for v in postgres elasticsearch logstash; do \
name="$${DATA_VOLUME_PREFIX:-linkedin-search}-$${v}-data"; \
docker volume inspect "$$name" >/dev/null 2>&1 \
&& echo " exists $$name" \
|| (docker volume create "$$name" >/dev/null && echo " created $$name"); \
done
.PHONY: prod-migrate
prod-migrate: ## Apply database migrations once, before rolling out a new image
$(COMPOSE_PROD) run --rm backend-migrate
.PHONY: prod .PHONY: prod
prod: env ## Start with the production overlay prod: env prod-volumes ## Start with the production overlay
$(COMPOSE_PROD) up --build -d $(COMPOSE_PROD) up --build -d
@echo "Run 'make prod-migrate' to apply schema changes - production containers do not migrate on boot."
.PHONY: down .PHONY: down
down: ## Stop the stack (keeps volumes) down: ## Stop the stack (keeps volumes)
$(COMPOSE) down $(COMPOSE) down
.PHONY: clean .PHONY: clean
clean: ## Stop the stack and delete all volumes clean: ## Stop the stack and delete all volumes (DESTRUCTIVE - dev stack only)
@echo "This deletes the database, the search index and the log queue of the *default* stack."
@printf 'Type "yes" to continue: ' && read ans && [ "$$ans" = "yes" ]
$(COMPOSE) down -v --remove-orphans $(COMPOSE) down -v --remove-orphans
.PHONY: logs .PHONY: logs
@@ -79,7 +99,7 @@ kibana-setup: ## Register the app-logs data view in Kibana
# --- Quality ----------------------------------------------------------------- # --- Quality -----------------------------------------------------------------
.PHONY: test .PHONY: test
test: ## Run the backend test suite inside the stack test: ## Run the backend test suite inside the stack
$(COMPOSE) exec -e DJANGO_SETTINGS_MODULE=config.settings.test backend pytest $(COMPOSE) run --rm backend-test
.PHONY: test-local .PHONY: test-local
test-local: ## Run the backend test suite against a local PostgreSQL test-local: ## Run the backend test suite against a local PostgreSQL
+2 -1
View File
@@ -10,6 +10,7 @@ htmlcov/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
tests/ # tests/ is intentionally not excluded here - see the root .dockerignore
# for why (the `dev` build stage needs it in the context).
*.sqlite3 *.sqlite3
*.log *.log
+45 -6
View File
@@ -50,16 +50,33 @@ RUN pip install --no-index --find-links=/wheels -r /tmp/requirements/base.txt \
&& rm -rf /wheels /tmp/requirements && rm -rf /wheels /tmp/requirements
WORKDIR /app WORKDIR /app
COPY --chown=app:app backend/ /app/ # Explicit list, not `COPY backend/ /app/`: the runtime image must not ship
# test code (or stray repo files like this Dockerfile itself), only what the
# server needs to run. `tests/` is added back for the `dev` stage below.
COPY --chown=app:app backend/apps/ /app/apps/
COPY --chown=app:app backend/config/ /app/config/
COPY --chown=app:app backend/manage.py backend/entrypoint.sh backend/gunicorn.conf.py backend/pyproject.toml /app/
COPY --chown=app:app data/ /data/ COPY --chown=app:app data/ /data/
# Static assets are only needed for the admin and the API docs, but baking them # Static assets are only needed for the admin and the API docs, but baking them
# in keeps the container stateless. # in keeps the container stateless. The step runs against the production
RUN DJANGO_SECRET_KEY=build-only-not-a-real-secret-000000000000000000000000000000000000 \ # settings on purpose: they are the ones this image ships with, and the dev
# module pulls in django-extensions which is not part of base.txt.
# WORKDIR creates /app as root before the COPY above populates it, and
# --chown on COPY only touches what it copies - the directory entry itself
# stays root-owned, which is enough to make anything that writes a *new* file
# straight into /app (e.g. pytest-cov's .coverage) fail with EACCES as the
# app user.
# The secret is generated per build and thrown away with the layer: nothing
# secret-shaped is ever committed to the repo or baked into the image, and
# collectstatic only needs *a* valid key, never a specific one. (A literal
# placeholder here would also trip the guard in config/settings/prod.py.)
RUN chown app:app /app \
&& chmod +x /app/entrypoint.sh \
&& DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(64))')" \
DATABASE_URL=postgres://build:build@localhost:5432/build \ DATABASE_URL=postgres://build:build@localhost:5432/build \
DJANGO_SETTINGS_MODULE=config.settings.dev \ DJANGO_SETTINGS_MODULE=config.settings.prod \
python manage.py collectstatic --noinput --clear \ python manage.py collectstatic --noinput --clear
&& chmod +x /app/entrypoint.sh
USER app USER app
EXPOSE 8000 EXPOSE 8000
@@ -69,3 +86,25 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--config", "/app/gunicorn.conf.py"] CMD ["gunicorn", "config.wsgi:application", "--config", "/app/gunicorn.conf.py"]
# -----------------------------------------------------------------------------
# Stage 3 - development
#
# Same layout as the runtime image plus the tooling from dev.txt (pytest,
# django-extensions, ...) so `config.settings.dev` can actually be imported.
# Selected through `target: dev` in docker-compose.dev.yml.
# -----------------------------------------------------------------------------
FROM runtime AS dev
USER root
ENV DJANGO_SETTINGS_MODULE=config.settings.dev
COPY backend/requirements/ /tmp/requirements/
RUN pip install -r /tmp/requirements/dev.txt \
&& rm -rf /tmp/requirements
# The one directory the runtime stage deliberately left out.
COPY --chown=app:app backend/tests/ /app/tests/
USER app
+74 -26
View File
@@ -80,6 +80,18 @@ FILTER_FIELDS = {
"companies": "company.keyword", "companies": "company.keyword",
} }
#: Maps a facet (as returned to the frontend) to the request attribute that
#: selects values *within that same facet*. Used to exclude a facet's own
#: filter from its own aggregation - see ``_build_aggregations``.
FACET_TO_FILTER_ATTR = {
"skills": "skills",
"job_title_role": "roles",
"seniority": "seniority",
"country": "countries",
"industry": "industries",
"company": "companies",
}
def build_query(request: SearchRequest) -> dict[str, Any]: def build_query(request: SearchRequest) -> dict[str, Any]:
"""Translate a search request into an Elasticsearch request body.""" """Translate a search request into an Elasticsearch request body."""
@@ -118,20 +130,26 @@ def build_query(request: SearchRequest) -> dict[str, Any]:
else: else:
must.append({"match_all": {}}) must.append({"match_all": {}})
active_filters = _active_filters(request)
range_filter = _range_filter(request)
body: dict[str, Any] = { body: dict[str, Any] = {
"query": { "query": {"bool": {"must": must, "should": should}},
"bool": {
"must": must,
"should": should,
"filter": _build_filters(request),
}
},
"from": request.offset, "from": request.offset,
"size": request.page_size, "size": request.page_size,
"track_total_hits": True, "track_total_hits": True,
"sort": _build_sort(request), "sort": _build_sort(request),
} }
# Filters live in ``post_filter``, not ``query.bool.filter``: post_filter
# narrows the hits and the total count *after* aggregations are computed,
# which is what lets a multi-select facet stay populated with its own
# sibling values - see ``_build_aggregations`` for the other half of the
# trick (each facet additionally excludes its own filter).
all_filters = [*active_filters.values(), *([range_filter] if range_filter else [])]
if all_filters:
body["post_filter"] = {"bool": {"filter": all_filters}}
if request.q: if request.q:
body["highlight"] = { body["highlight"] = {
"pre_tags": ["<mark>"], "pre_tags": ["<mark>"],
@@ -140,31 +158,33 @@ def build_query(request: SearchRequest) -> dict[str, Any]:
} }
if request.include_facets: if request.include_facets:
body["aggs"] = _build_aggregations() body["aggs"] = _build_aggregations(active_filters, range_filter)
return body return body
def _build_filters(request: SearchRequest) -> list[dict[str, Any]]: def _active_filters(request: SearchRequest) -> dict[str, dict[str, Any]]:
filters: list[dict[str, Any]] = [] """One ``terms`` clause per attribute that actually has a value selected."""
filters: dict[str, dict[str, Any]] = {}
for attribute, field_name in FILTER_FIELDS.items(): for attribute, field_name in FILTER_FIELDS.items():
values = getattr(request, attribute) values = getattr(request, attribute)
if values: if values:
# ``terms`` is OR within one filter; separate filters are ANDed, # ``terms`` is OR within one filter; separate filters are ANDed,
# which matches how users read the UI: "python OR django" skills, # which matches how users read the UI: "python OR django" skills,
# AND located in Germany. # AND located in Germany.
filters.append({"terms": {field_name: list(values)}}) filters[attribute] = {"terms": {field_name: list(values)}}
return filters
if request.min_experience is not None or request.max_experience is not None:
def _range_filter(request: SearchRequest) -> dict[str, Any] | None:
if request.min_experience is None and request.max_experience is None:
return None
bounds: dict[str, int] = {} bounds: dict[str, int] = {}
if request.min_experience is not None: if request.min_experience is not None:
bounds["gte"] = request.min_experience bounds["gte"] = request.min_experience
if request.max_experience is not None: if request.max_experience is not None:
bounds["lte"] = request.max_experience bounds["lte"] = request.max_experience
filters.append({"range": {"years_experience": bounds}}) return {"range": {"years_experience": bounds}}
return filters
def _build_sort(request: SearchRequest) -> list[Any]: def _build_sort(request: SearchRequest) -> list[Any]:
@@ -177,12 +197,35 @@ def _build_sort(request: SearchRequest) -> list[Any]:
return ["_score", {"id": "asc"}] # deterministic tie-break return ["_score", {"id": "asc"}] # deterministic tie-break
def _build_aggregations() -> dict[str, Any]: def _build_aggregations(
active_filters: dict[str, dict[str, Any]], range_filter: dict[str, Any] | None
) -> dict[str, Any]:
"""
One ``terms`` aggregation per facet, each scoped by every *other* active
filter - the standard multi-select faceting pattern. Without this, a
facet's own filter (applied globally through ``post_filter``) would
already have narrowed the document set before the aggregation runs, so
ticking "Engineering" under Job family would make every other job family
option vanish from that same list instead of just narrowing the results.
The experience range applies to every facet unconditionally: it is not a
facet with its own checkboxes, so it is never excluded.
"""
size = settings.SEARCH["FACET_SIZE"] size = settings.SEARCH["FACET_SIZE"]
return { aggs: dict[str, Any] = {}
name: {"terms": {"field": field_name, "size": size, "order": {"_count": "desc"}}} for name, field_name in FACET_FIELDS.items():
for name, field_name in FACET_FIELDS.items() own_attribute = FACET_TO_FILTER_ATTR[name]
} other_filters = [
clause for attribute, clause in active_filters.items() if attribute != own_attribute
]
if range_filter is not None:
other_filters.append(range_filter)
terms_agg = {"terms": {"field": field_name, "size": size, "order": {"_count": "desc"}}}
if other_filters:
aggs[name] = {"filter": {"bool": {"filter": other_filters}}, "aggs": {name: terms_agg}}
else:
aggs[name] = terms_agg
return aggs
class ElasticsearchBackend(SearchBackend): class ElasticsearchBackend(SearchBackend):
@@ -269,11 +312,16 @@ class ElasticsearchBackend(SearchBackend):
@staticmethod @staticmethod
def _to_facets(aggregations: dict[str, Any]) -> dict[str, list[FacetBucket]]: def _to_facets(aggregations: dict[str, Any]) -> dict[str, list[FacetBucket]]:
return { facets: dict[str, list[FacetBucket]] = {}
name: [ for name, payload in aggregations.items():
# A facet scoped by other filters is wrapped in an outer `filter`
# aggregation (see ``_build_aggregations``); unwrap it to reach the
# `terms` buckets. When no other filter was active it is the bare
# `terms` aggregation already.
terms_payload = payload.get(name, payload)
facets[name] = [
FacetBucket(key=str(bucket["key"]), count=bucket["doc_count"]) FacetBucket(key=str(bucket["key"]), count=bucket["doc_count"])
for bucket in payload.get("buckets", []) for bucket in terms_payload.get("buckets", [])
if bucket["key"] != "" if bucket["key"] != ""
] ]
for name, payload in aggregations.items() return facets
}
+83 -47
View File
@@ -42,14 +42,26 @@ _TOKEN_RE = re.compile(r"[\w']+", re.UNICODE)
_FACET_LIMIT = 25 _FACET_LIMIT = 25
_MIN_TRIGRAM_SIMILARITY = 0.3 _MIN_TRIGRAM_SIMILARITY = 0.3
#: Maps a facet (as returned to the frontend) to the request attribute that
#: selects values *within that same facet*. Used to exclude a facet's own
#: filter from its own count - see ``_facets``.
_FACET_TO_FILTER_ATTR = {
"skills": "skills",
"job_title_role": "roles",
"seniority": "seniority",
"country": "countries",
"industry": "industries",
"company": "companies",
}
class PostgresBackend(SearchBackend): class PostgresBackend(SearchBackend):
name = "postgres" name = "postgres"
def search(self, request: SearchRequest) -> SearchResult: def search(self, request: SearchRequest) -> SearchResult:
started = time.perf_counter() started = time.perf_counter()
queryset = self._apply_filters(Profile.objects.all(), request) keyword_queryset = self._apply_keyword(Profile.objects.all(), request.q)
queryset = self._apply_keyword(queryset, request.q) queryset = self._apply_filters(keyword_queryset, request)
total = queryset.count() total = queryset.count()
page = ( page = (
@@ -60,7 +72,7 @@ class PostgresBackend(SearchBackend):
] ]
) )
hits = [self._to_hit(profile, request.q) for profile in page] hits = [self._to_hit(profile, request.q) for profile in page]
facets = self._facets(queryset) if request.include_facets else {} facets = self._facets(keyword_queryset, request) if request.include_facets else {}
return SearchResult( return SearchResult(
total=total, total=total,
@@ -87,23 +99,35 @@ class PostgresBackend(SearchBackend):
# -- Query construction ------------------------------------------------- # -- Query construction -------------------------------------------------
@staticmethod @staticmethod
def _apply_filters(queryset: QuerySet[Profile], request: SearchRequest) -> QuerySet[Profile]: def _apply_filters(
if request.skills: queryset: QuerySet[Profile], request: SearchRequest, *, exclude: str | None = None
) -> QuerySet[Profile]:
"""
Apply every active filter except ``exclude``.
``exclude`` is how a facet's own count query avoids narrowing itself:
pass e.g. ``exclude="roles"`` when computing the job-family facet so
ticking "Engineering" does not make "Human resources" disappear from
that same list. The main hits queryset calls this with no exclusion.
"""
if request.skills and exclude != "skills":
# Every selected skill widens the result set (OR), mirroring the # Every selected skill widens the result set (OR), mirroring the
# ``terms`` filter used by the Elasticsearch backend. # ``terms`` filter used by the Elasticsearch backend.
queryset = queryset.filter( queryset = queryset.filter(
skills__slug__in=[Skill.make_slug(s) for s in request.skills] skills__slug__in=[Skill.make_slug(s) for s in request.skills]
) )
if request.roles: if request.roles and exclude != "roles":
queryset = queryset.filter(job_title_role__in=request.roles) queryset = queryset.filter(job_title_role__in=request.roles)
if request.seniority: if request.seniority and exclude != "seniority":
queryset = queryset.filter(seniority_levels__overlap=list(request.seniority)) queryset = queryset.filter(seniority_levels__overlap=list(request.seniority))
if request.countries: if request.countries and exclude != "countries":
queryset = queryset.filter(location_country__in=request.countries) queryset = queryset.filter(location_country__in=request.countries)
if request.industries: if request.industries and exclude != "industries":
queryset = queryset.filter(industry__in=request.industries) queryset = queryset.filter(industry__in=request.industries)
if request.companies: if request.companies and exclude != "companies":
queryset = queryset.filter(current_company__name__in=request.companies) queryset = queryset.filter(current_company__name__in=request.companies)
# The experience range is not a facet with its own checkboxes, so it
# always applies - it is never the thing being excluded.
if request.min_experience is not None: if request.min_experience is not None:
queryset = queryset.filter(years_experience__gte=request.min_experience) queryset = queryset.filter(years_experience__gte=request.min_experience)
if request.max_experience is not None: if request.max_experience is not None:
@@ -152,53 +176,65 @@ class PostgresBackend(SearchBackend):
return queryset.order_by("-rank", "full_name", "id") return queryset.order_by("-rank", "full_name", "id")
# -- Facets ------------------------------------------------------------- # -- Facets -------------------------------------------------------------
def _facets(self, queryset: QuerySet[Profile]) -> dict[str, list[FacetBucket]]: def _facets(
self, keyword_queryset: QuerySet[Profile], request: SearchRequest
) -> dict[str, list[FacetBucket]]:
"""
One count per facet, each computed on a queryset that excludes that
facet's *own* filter.
This is what keeps a multi-select facet fully populated once one of
its values is ticked: the job-family facet must still list "Human
resources" after "Engineering" is selected, while every *other* facet
(country, seniority, ...) does narrow to the engineering-only result
set. Six independent queries instead of one is a deliberate
trade-off - see the module docstring on why this backend does not aim
to match Elasticsearch's scale, only its contract.
"""
return {
name: self._facet_counts(
name, self._apply_filters(keyword_queryset, request, exclude=attribute)
)
for name, attribute in _FACET_TO_FILTER_ATTR.items()
}
@staticmethod
def _facet_counts(facet_name: str, queryset: QuerySet[Profile]) -> list[FacetBucket]:
ids = list(queryset.values_list("id", flat=True)) ids = list(queryset.values_list("id", flat=True))
if not ids: if not ids:
return { return []
name: []
for name in (
"skills",
"job_title_role",
"seniority",
"country",
"industry",
"company",
)
}
skills = Skill.objects.filter(profile_skills__profile_id__in=ids).values_list( if facet_name == "skills":
counter = Counter(
Skill.objects.filter(profile_skills__profile_id__in=ids).values_list(
"name", flat=True "name", flat=True
) )
rows = Profile.objects.filter(id__in=ids).values( )
"job_title_role", "location_country", "industry", "current_company__name", elif facet_name == "seniority":
"seniority_levels", counter = Counter()
) # fmt: skip for levels in Profile.objects.filter(id__in=ids).values_list(
"seniority_levels", flat=True
):
for level in levels or []:
counter[level] += 1
else:
column = {
"job_title_role": "job_title_role",
"country": "location_country",
"industry": "industry",
"company": "current_company__name",
}[facet_name]
counter = Counter(
value or "" for value in Profile.objects.filter(id__in=ids).values_list(
column, flat=True
)
)
counters: dict[str, Counter] = { return [
"skills": Counter(skills),
"job_title_role": Counter(),
"seniority": Counter(),
"country": Counter(),
"industry": Counter(),
"company": Counter(),
}
for row in rows:
counters["job_title_role"][row["job_title_role"]] += 1
counters["country"][row["location_country"]] += 1
counters["industry"][row["industry"]] += 1
counters["company"][row["current_company__name"] or ""] += 1
for level in row["seniority_levels"] or []:
counters["seniority"][level] += 1
return {
name: [
FacetBucket(key=key, count=count) FacetBucket(key=key, count=count)
for key, count in counter.most_common(_FACET_LIMIT) for key, count in counter.most_common(_FACET_LIMIT)
if key if key
] ]
for name, counter in counters.items()
}
# -- Result mapping ----------------------------------------------------- # -- Result mapping -----------------------------------------------------
@classmethod @classmethod
+33 -2
View File
@@ -30,7 +30,7 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from elasticsearch import Elasticsearch from elasticsearch import BadRequestError, Elasticsearch
from elasticsearch.helpers import bulk from elasticsearch.helpers import bulk
from apps.profiles.models import Profile from apps.profiles.models import Profile
@@ -227,8 +227,33 @@ def _default_queryset(): # noqa: ANN202
) )
# Creating an index waits for the master to ack and the shard to be allocated,
# which can take far longer than the per-request timeout used for searches.
INDEX_CREATE_TIMEOUT = 120
def create_index(client: Elasticsearch, name: str) -> None: def create_index(client: Elasticsearch, name: str) -> None:
client.indices.create(index=name, settings=INDEX_SETTINGS, mappings=INDEX_MAPPINGS) """
Create the physical index, tolerating a retried request.
``indices.create`` is not idempotent, but the client runs with
``retry_on_timeout``: when the cluster acks the creation more slowly than
the request timeout, the retry comes back as
``resource_already_exists_exception`` and the rebuild fails even though the
index is there. The name carries a timestamp generated moments earlier, so
an index already under that name can only be our own first attempt.
"""
try:
client.options(request_timeout=INDEX_CREATE_TIMEOUT).indices.create(
index=name, settings=INDEX_SETTINGS, mappings=INDEX_MAPPINGS
)
except BadRequestError as exc:
if exc.error != "resource_already_exists_exception":
raise
logger.warning(
"index_create_retried",
extra={"event": "index_create_retried", "index": name},
)
def rebuild_index(client: Elasticsearch | None = None) -> dict[str, Any]: def rebuild_index(client: Elasticsearch | None = None) -> dict[str, Any]:
@@ -245,6 +270,9 @@ def rebuild_index(client: Elasticsearch | None = None) -> dict[str, Any]:
create_index(client, new_index) create_index(client, new_index)
logger.info("index_created", extra={"event": "index_created", "index": new_index}) logger.info("index_created", extra={"event": "index_created", "index": new_index})
# Anything that goes wrong from here on leaves an index nothing points at;
# drop it rather than accumulating one orphan per failed attempt.
try:
succeeded, errors = bulk( succeeded, errors = bulk(
client, client,
({"_index": new_index, **doc} for doc in iter_documents()), ({"_index": new_index, **doc} for doc in iter_documents()),
@@ -252,6 +280,9 @@ def rebuild_index(client: Elasticsearch | None = None) -> dict[str, Any]:
stats_only=False, stats_only=False,
) )
client.indices.refresh(index=new_index) client.indices.refresh(index=new_index)
except Exception:
client.indices.delete(index=new_index, ignore_unavailable=True)
raise
previous = _current_indices(client, alias) previous = _current_indices(client, alias)
actions: list[dict[str, Any]] = [{"add": {"index": new_index, "alias": alias}}] actions: list[dict[str, Any]] = [{"add": {"index": new_index, "alias": alias}}]
+1 -1
View File
@@ -23,7 +23,7 @@ FACET_FIELDS = {
"job_title_role": "job_title_role", "job_title_role": "job_title_role",
"seniority": "seniority_levels", "seniority": "seniority_levels",
"country": "location_country", "country": "location_country",
"industry": "industry", "industry": "industry.keyword",
"company": "company.keyword", "company": "company.keyword",
} }
+16
View File
@@ -64,6 +64,9 @@ INSTALLED_APPS = [
"django_filters", "django_filters",
"corsheaders", "corsheaders",
"drf_spectacular", "drf_spectacular",
# Serves the Swagger UI / ReDoc bundles from our own origin instead of a
# CDN, which is what keeps the strict CSP in frontend/nginx.conf working.
"drf_spectacular_sidecar",
# Local # Local
"apps.core", "apps.core",
"apps.accounts", "apps.accounts",
@@ -212,8 +215,21 @@ SPECTACULAR_SETTINGS = {
"COMPONENT_SPLIT_REQUEST": True, "COMPONENT_SPLIT_REQUEST": True,
"SCHEMA_PATH_PREFIX": "/api/v1", "SCHEMA_PATH_PREFIX": "/api/v1",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
# Serve the UI assets from this origin (drf-spectacular-sidecar) rather
# than the default cdn.jsdelivr.net "@latest" URLs: the CSP blocks
# third-party scripts, so the CDN version renders a blank page.
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
} }
# Whether /api/docs/, /api/redoc/ and /api/schema/ are routed at all. They
# describe every endpoint, parameter and response shape in the service, so
# production keeps them off by default and opts in explicitly (behind an
# authenticating proxy) rather than publishing the full API surface to
# anonymous callers - see config/urls.py.
SERVE_API_DOCS = env.bool("SERVE_API_DOCS", default=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CORS / CSRF # CORS / CSRF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+31 -1
View File
@@ -7,7 +7,7 @@ back to an insecure default.
""" """
from .base import * # noqa: F403 from .base import * # noqa: F403
from .base import DEBUG, SECRET_KEY, env from .base import DATABASES, DEBUG, SECRET_KEY, env
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fail fast on unsafe configuration # Fail fast on unsafe configuration
@@ -18,11 +18,36 @@ if DEBUG: # pragma: no cover - configuration guard
if len(SECRET_KEY) < 50: # pragma: no cover - configuration guard if len(SECRET_KEY) < 50: # pragma: no cover - configuration guard
raise RuntimeError("DJANGO_SECRET_KEY must be at least 50 characters in production.") raise RuntimeError("DJANGO_SECRET_KEY must be at least 50 characters in production.")
# A length check alone is false comfort: the placeholder shipped in
# .env.example is 68 characters long and would sail straight through it.
_PLACEHOLDER_MARKERS = ("change-me", "changeme", "build-only", "not-a-real-secret")
def _reject_placeholder(name: str, value: str) -> None: # pragma: no cover - guard
lowered = value.lower()
if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
raise RuntimeError(
f"{name} still holds a placeholder value from .env.example. "
"Generate a real secret before deploying: "
'python -c "import secrets; print(secrets.token_urlsafe(64))"'
)
_reject_placeholder("DJANGO_SECRET_KEY", SECRET_KEY)
_reject_placeholder("POSTGRES_PASSWORD", DATABASES["default"].get("PASSWORD") or "")
_reject_placeholder("ELASTICSEARCH_PASSWORD", env("ELASTICSEARCH_PASSWORD", default=""))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Transport security # Transport security
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True) SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Container/orchestrator probes hit the API over plain HTTP on localhost, so
# without an exemption every probe gets a 301 to https. `curl -fsS` treats a
# 3xx as success, which means a completely broken container would still report
# itself healthy - the probe has to reach the real view to be worth anything.
SECURE_REDIRECT_EXEMPT = [r"^api/v1/health/"]
SECURE_HSTS_SECONDS = 31_536_000 # one year SECURE_HSTS_SECONDS = 31_536_000 # one year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True SECURE_HSTS_PRELOAD = True
@@ -40,3 +65,8 @@ ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/")
# Deny by default; CORS_ALLOWED_ORIGINS must be set explicitly. # Deny by default; CORS_ALLOWED_ORIGINS must be set explicitly.
CORS_ALLOW_ALL_ORIGINS = False CORS_ALLOW_ALL_ORIGINS = False
# The interactive docs describe every endpoint and payload in the service and
# are served unauthenticated. Off unless the operator explicitly opts in (for
# an internal-only origin, say) - see config/urls.py.
SERVE_API_DOCS = env.bool("SERVE_API_DOCS", default=False)
+9 -2
View File
@@ -25,7 +25,14 @@ api_v1 = [
urlpatterns = [ urlpatterns = [
path(getattr(settings, "ADMIN_URL", "admin/"), admin.site.urls), path(getattr(settings, "ADMIN_URL", "admin/"), admin.site.urls),
path("api/v1/", include((api_v1, "api"), namespace="v1")), path("api/v1/", include((api_v1, "api"), namespace="v1")),
# OpenAPI schema and the two documentation renderers. ]
# OpenAPI schema and the two documentation renderers. They are served without
# authentication, and together they document every endpoint, parameter and
# response shape in the service - useful everywhere except on a public
# production origin, where SERVE_API_DOCS defaults to false.
if getattr(settings, "SERVE_API_DOCS", True):
urlpatterns += [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path( path(
"api/docs/", "api/docs/",
@@ -33,7 +40,7 @@ urlpatterns = [
name="swagger-ui", name="swagger-ui",
), ),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
] ]
admin.site.site_header = "LinkedIn Profile Search" admin.site.site_header = "LinkedIn Profile Search"
admin.site.site_title = "LinkedIn Profile Search" admin.site.site_title = "LinkedIn Profile Search"
+7 -1
View File
@@ -19,8 +19,14 @@ psycopg[binary]~=3.2.3
djangorestframework-simplejwt~=5.3.1 djangorestframework-simplejwt~=5.3.1
argon2-cffi~=23.1.0 argon2-cffi~=23.1.0
# API schema & interactive documentation # API schema & interactive documentation.
# The sidecar ships the Swagger UI / ReDoc bundles as static files. Without
# it drf-spectacular points the browser at cdn.jsdelivr.net, which the CSP in
# frontend/nginx.conf ("script-src 'self'") blocks, leaving a blank page - and
# the CDN URL is pinned to "@latest", which is a supply-chain risk in its own
# right. Self-hosting fixes both and lets the docs work air-gapped.
drf-spectacular~=0.27.2 drf-spectacular~=0.27.2
drf-spectacular-sidecar~=2026.9.1
# Cross origin access for the SPA # Cross origin access for the SPA
django-cors-headers~=4.4.0 django-cors-headers~=4.4.0
+90 -12
View File
@@ -11,15 +11,22 @@ from __future__ import annotations
from apps.search.backends.elastic import build_query from apps.search.backends.elastic import build_query
from apps.search.types import SORT_EXPERIENCE_DESC, SORT_NAME, SearchRequest 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: def _bool(request: SearchRequest) -> dict:
return build_query(request)["query"]["bool"] 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: def test_empty_query_matches_everything() -> None:
clause = _bool(SearchRequest()) body = build_query(SearchRequest())
assert clause["must"] == [{"match_all": {}}] assert body["query"]["bool"]["must"] == [{"match_all": {}}]
assert clause["filter"] == [] assert "post_filter" not in body
def test_keyword_query_uses_fuzzy_multi_match_and_phrase_boost() -> None: 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" 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( clause = _bool(
SearchRequest(skills=("python", "django"), countries=("germany",), seniority=("senior",)) 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 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: def test_experience_range_is_translated_to_a_range_filter() -> None:
clause = _bool(SearchRequest(min_experience=5, max_experience=10)) filters = _post_filters(SearchRequest(min_experience=5, max_experience=10))
assert {"range": {"years_experience": {"gte": 5, "lte": 10}}} in clause["filter"] assert {"range": {"years_experience": {"gte": 5, "lte": 10}}} in filters
open_ended = _bool(SearchRequest(min_experience=5)) open_ended = _post_filters(SearchRequest(min_experience=5))
assert {"range": {"years_experience": {"gte": 5}}} in open_ended["filter"] assert {"range": {"years_experience": {"gte": 5}}} in open_ended
def test_pagination_maps_to_from_and_size() -> None: 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: def test_facets_can_be_switched_off() -> None:
assert "aggs" in build_query(SearchRequest()) assert "aggs" in build_query(SearchRequest())
assert "aggs" not in build_query(SearchRequest(include_facets=False)) 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}}}
]
+44
View File
@@ -90,6 +90,50 @@ def test_facets_reflect_the_filtered_result_set(
assert response.data["facets"]["skills"] 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 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] == "john recruiter"
assert names(search(auth_client(analyst), sort="experience"))[0] == "maria frontend" assert names(search(auth_client(analyst), sort="experience"))[0] == "maria frontend"
+3 -1
View File
@@ -11,7 +11,9 @@
services: services:
backend: backend:
build: build:
target: runtime # The dev stage adds dev.txt (django-extensions, pytest, ...) on top of
# the runtime image; config.settings.dev cannot be imported without it.
target: dev
command: command:
- python - python
- manage.py - manage.py
+57
View File
@@ -53,16 +53,48 @@ services:
DJANGO_SETTINGS_MODULE: config.settings.prod DJANGO_SETTINGS_MODULE: config.settings.prod
DJANGO_DEBUG: "false" DJANGO_DEBUG: "false"
SECURE_SSL_REDIRECT: "true" SECURE_SSL_REDIRECT: "true"
# Off unless deliberately opted in: /api/docs/ and /api/schema/ publish
# the entire API surface to anonymous callers.
SERVE_API_DOCS: ${SERVE_API_DOCS:-false}
ELASTICSEARCH_USERNAME: elastic ELASTICSEARCH_USERNAME: elastic
ELASTICSEARCH_PASSWORD: ${ELASTIC_PASSWORD} ELASTICSEARCH_PASSWORD: ${ELASTIC_PASSWORD}
# A production container must never mutate data on boot. # A production container must never mutate data on boot.
SEED_DATASET: "false" SEED_DATASET: "false"
SEED_USERS: "false" SEED_USERS: "false"
# Nor schema-migrate on boot: with `replicas: 2` below, both containers
# would start `manage.py migrate` at the same moment and race each other
# through the same DDL. Migrations are a deliberate, one-at-a-time
# deploy step instead - `make prod-migrate`, which runs the one-shot
# `backend-migrate` service defined below.
RUN_MIGRATIONS: "false"
deploy: deploy:
replicas: 2 replicas: 2
resources: resources:
limits: { cpus: "1.0", memory: 768m } limits: { cpus: "1.0", memory: 768m }
# ---------------------------------------------------------------------------
# One-shot schema migration, run by `make prod-migrate` before rolling out a
# new image. Kept out of the default set by the "migrate" profile so a plain
# `up` never starts it.
# ---------------------------------------------------------------------------
backend-migrate:
extends:
file: docker-compose.yml
service: backend
profiles: ["migrate"]
restart: "no"
entrypoint: []
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
DJANGO_SETTINGS_MODULE: config.settings.prod
DJANGO_DEBUG: "false"
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}
DATABASE_URL: postgres://${POSTGRES_USER:-linkedin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-linkedin}
# Migrating must not depend on the search cluster being reachable.
SEARCH_BACKEND: postgres
SECURE_SSL_REDIRECT: "false"
deploy: !reset null
frontend: frontend:
deploy: deploy:
resources: resources:
@@ -72,3 +104,28 @@ services:
deploy: deploy:
resources: resources:
limits: { memory: 1g } limits: { memory: 1g }
# =============================================================================
# Data durability
#
# The base compose file declares these as ordinary project volumes, which
# `docker compose down -v` (and therefore `make clean`) deletes without
# asking. Marking them external in production flips that guarantee: Compose
# treats them as owned by the operator, never creates them and - crucially -
# never removes them, so a mistyped teardown cannot destroy the database or
# the search index.
#
# They must exist before the first `make prod`; `make prod-volumes` creates
# them. Back them up with your usual volume/snapshot tooling - external only
# means Compose will not delete them, not that they are backed up.
# =============================================================================
volumes:
postgres-data:
external: true
name: ${DATA_VOLUME_PREFIX:-linkedin-search}-postgres-data
elasticsearch-data:
external: true
name: ${DATA_VOLUME_PREFIX:-linkedin-search}-elasticsearch-data
logstash-data:
external: true
name: ${DATA_VOLUME_PREFIX:-linkedin-search}-logstash-data
+46 -4
View File
@@ -93,7 +93,9 @@ services:
ELASTICSEARCH_HOSTS: http://elasticsearch:9200 ELASTICSEARCH_HOSTS: http://elasticsearch:9200
ELASTICSEARCH_USERNAME: ${ELASTICSEARCH_USERNAME:-} ELASTICSEARCH_USERNAME: ${ELASTICSEARCH_USERNAME:-}
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-} ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-}
xpack.monitoring.enabled: "false" # No `xpack.monitoring.enabled` here on purpose: any logstash.* variable
# makes the image entrypoint rewrite config/logstash.yml, which is
# mounted read-only below. The setting already lives in that file.
volumes: volumes:
- ./elk/logstash/pipeline:/usr/share/logstash/pipeline:ro - ./elk/logstash/pipeline:/usr/share/logstash/pipeline:ro
- ./elk/logstash/templates:/usr/share/logstash/templates:ro - ./elk/logstash/templates:/usr/share/logstash/templates:ro
@@ -142,6 +144,9 @@ services:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
# Explicit: the Dockerfile has a later `dev` stage that must not leak
# into the default stack.
target: runtime
<<: *service-defaults <<: *service-defaults
environment: environment:
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings.prod} DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings.prod}
@@ -150,6 +155,10 @@ services:
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend}
DJANGO_ADMIN_URL: ${DJANGO_ADMIN_URL:-admin/} DJANGO_ADMIN_URL: ${DJANGO_ADMIN_URL:-admin/}
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-false} SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-false}
# This stack is the local/demo one, so the interactive API docs are on.
# The production overlay turns them off - they document every endpoint
# and are served unauthenticated.
SERVE_API_DOCS: ${SERVE_API_DOCS:-true}
ENVIRONMENT: ${ENVIRONMENT:-development} ENVIRONMENT: ${ENVIRONMENT:-development}
SERVICE_NAME: ${SERVICE_NAME:-linkedin-search-api} SERVICE_NAME: ${SERVICE_NAME:-linkedin-search-api}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080}
@@ -197,6 +206,37 @@ services:
- no-new-privileges:true - no-new-privileges:true
networks: [backend-net, frontend-net] networks: [backend-net, frontend-net]
# ---------------------------------------------------------------------------
# Test runner - never started by `up`/`dev`/`prod` (the "test" profile keeps
# it out of the default set); invoked only via `make test`, which runs
# `docker compose run --rm backend-test`.
#
# The `backend` service above intentionally runs the `runtime` build stage,
# which ships no test tooling - that is what keeps the production image
# minimal. `pytest`, `django-extensions` etc. live only in the `dev` stage,
# so the test runner builds that stage instead. It talks to the same
# Postgres the rest of the stack uses; pytest-django creates and migrates
# its own throwaway `test_<db>` database there, and `config.settings.test`
# forces the search backend to Postgres, so Elasticsearch/Redis are not
# dependencies here.
# ---------------------------------------------------------------------------
backend-test:
build:
context: .
dockerfile: backend/Dockerfile
target: dev
profiles: ["test"]
entrypoint: []
command: ["pytest"]
environment:
DJANGO_SETTINGS_MODULE: config.settings.test
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}
DATABASE_URL: postgres://${POSTGRES_USER:-linkedin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-linkedin}
depends_on:
postgres:
condition: service_healthy
networks: [backend-net]
frontend: frontend:
build: build:
context: . context: .
@@ -209,10 +249,12 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
read_only: true read_only: true
# A tmpfs mount is created root:root 0755 and shadows whatever the image
# chowned at build time, so nginx (uid 101) needs the mode set explicitly.
tmpfs: tmpfs:
- /tmp - /tmp:mode=1777
- /var/run - /var/run:mode=1777
- /var/cache/nginx - /var/cache/nginx:mode=1777
networks: [frontend-net] networks: [frontend-net]
networks: networks:
+6 -1
View File
@@ -29,6 +29,9 @@ FROM nginx:1.27-alpine AS runtime
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
COPY frontend/nginx.conf /etc/nginx/conf.d/app.conf COPY frontend/nginx.conf /etc/nginx/conf.d/app.conf
# Included by the location blocks in app.conf - see each file's own comment.
COPY frontend/security-headers.conf /etc/nginx/conf.d/security-headers.conf
COPY frontend/proxy-backend.conf /etc/nginx/conf.d/proxy-backend.conf
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# nginx:alpine already ships an unprivileged "nginx" user; the official # nginx:alpine already ships an unprivileged "nginx" user; the official
@@ -39,5 +42,7 @@ RUN touch /var/run/nginx.pid \
USER nginx USER nginx
EXPOSE 8080 EXPOSE 8080
# 127.0.0.1, not localhost: nginx listens on IPv4 only, and busybox wget
# resolves localhost to ::1 first without falling back to IPv4.
HEALTHCHECK --interval=30s --timeout=4s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=4s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/healthz || exit 1 CMD wget -q --spider http://127.0.0.1:8080/healthz || exit 1
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod';
/**
* Runtime mirrors of the TypeScript interfaces in `src/types/index.ts`.
*
* A TS interface is a compile-time-only promise: nothing stops the Django
* side from renaming a field, changing its nullability, or dropping a facet,
* and the frontend would only find out at runtime, in production, as a blank
* screen or a silently-wrong render. These schemas are validated against the
* real API response in `search-response.contract.test.ts`, so that class of
* drift fails a test instead.
*/
export const FacetBucketSchema = z.object({
key: z.string(),
count: z.number().int().nonnegative(),
});
export const FACET_NAMES = [
'skills',
'job_title_role',
'seniority',
'country',
'industry',
'company',
] as const;
export const SearchHitSchema = z.object({
id: z.number(),
score: z.number(),
full_name: z.string(),
headline: z.string(),
job_title: z.string(),
job_title_role: z.string(),
seniority_levels: z.array(z.string()),
company: z.string(),
industry: z.string(),
location_name: z.string(),
location_country: z.string(),
years_experience: z.number().nullable(),
linkedin_url: z.string(),
skills: z.array(z.string()),
highlight: z.record(z.string(), z.array(z.string())),
});
export const SearchResponseSchema = z.object({
count: z.number().int().nonnegative(),
page: z.number().int().positive(),
page_size: z.number().int().positive(),
total_pages: z.number().int().nonnegative(),
took_ms: z.number().int().nonnegative(),
backend: z.string(),
results: z.array(SearchHitSchema),
facets: z.record(z.enum(FACET_NAMES), z.array(FacetBucketSchema)),
});
export const UserSchema = z.object({
id: z.number(),
email: z.string(),
full_name: z.string(),
role: z.enum(['admin', 'analyst', 'viewer']),
can_view_pii: z.boolean(),
date_joined: z.string(),
});
export const TokenPairSchema = z.object({
access: z.string(),
refresh: z.string(),
user: UserSchema,
});
export const ApiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
request_id: z.string(),
}),
});
@@ -0,0 +1,92 @@
/**
* Contract test: hits the real, running API (not a mock) and validates the
* response against the same shape `src/types/index.ts` promises the rest of
* the frontend. This is the third kind of test in this suite, distinct from
* the other two:
*
* - unit tests (Vitest + Testing Library, `src/**\/*.test.tsx`) check
* frontend logic in isolation with a fake backend;
* - the e2e test (Playwright, `e2e/`) checks the full user-facing flow
* through a real browser against the real stack;
* - this one checks that the *data shape* the two sides agree on has not
* silently drifted - a Django field rename, a nullability change, or a
* dropped facet breaks this test even though it would type-check fine on
* both sides in isolation (the frontend's `SearchResponse` interface is
* just an assertion about what the backend sends, never verified against
* the backend itself anywhere else in this codebase).
*
* Requires the docker compose stack to be running (`make up`); not part of
* `npm test`, run separately via `npm run test:contract`.
*/
import { describe, expect, it } from 'vitest';
import { ApiErrorSchema, FACET_NAMES, SearchResponseSchema, TokenPairSchema } from './schemas';
import { API_BASE_URL, DEMO_ANALYST } from './support/env';
async function login(): Promise<string> {
const response = await fetch(`${API_BASE_URL}/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(DEMO_ANALYST),
});
const body = await response.json();
const parsed = TokenPairSchema.parse(body);
return parsed.access;
}
describe('search response contract', () => {
it('matches the SearchResponse shape the frontend types promise', async () => {
const token = await login();
const response = await fetch(`${API_BASE_URL}/search/?roles=engineering&page_size=5`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.status).toBe(200);
const body = await response.json();
const result = SearchResponseSchema.safeParse(body);
if (!result.success) {
throw new Error(`API response no longer matches SearchResponse:\n${result.error.message}`);
}
});
it('always returns every facet the frontend renders a filter group for', async () => {
// FilterPanel.tsx hard-codes these six facet names; a backend response
// missing one would leave that whole filter group silently empty.
const token = await login();
const response = await fetch(`${API_BASE_URL}/search/?page_size=1`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = SearchResponseSchema.parse(await response.json());
for (const facet of FACET_NAMES) {
expect(body.facets).toHaveProperty(facet);
expect(Array.isArray(body.facets[facet])).toBe(true);
}
});
it('returns real results for the seeded demo dataset', async () => {
const token = await login();
const response = await fetch(`${API_BASE_URL}/search/?page_size=5`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = SearchResponseSchema.parse(await response.json());
expect(body.count).toBeGreaterThan(0);
expect(body.results.length).toBeGreaterThan(0);
});
it('an unauthorized request matches the ApiError envelope the frontend error handler expects', async () => {
// api/client.ts's `toError()` reaches into `error.response.data.error.*`
// unconditionally - if the backend ever changed this envelope, every
// error message in the UI would silently become empty.
const response = await fetch(`${API_BASE_URL}/search/`);
expect(response.status).toBe(401);
const body = await response.json();
const result = ApiErrorSchema.safeParse(body);
if (!result.success) {
throw new Error(`401 response no longer matches ApiError:\n${result.error.message}`);
}
});
});
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
/** Same idea as e2e/support/env.ts - read the seeded demo credentials from `.env` rather than hard-coding them. */
function readDotEnv(): Record<string, string> {
const path = fileURLToPath(new URL('../../../.env', import.meta.url));
const text = readFileSync(path, 'utf-8');
const values: Record<string, string> = {};
for (const line of text.split('\n')) {
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
const key = match?.[1];
if (key !== undefined) values[key] = match?.[2] ?? '';
}
return values;
}
const env = readDotEnv();
export const DEMO_ANALYST = {
email: env.DEMO_ANALYST_EMAIL ?? 'analyst@example.com',
password: env.DEMO_ANALYST_PASSWORD ?? 'Analyst-Passw0rd!',
};
export const API_BASE_URL = process.env.E2E_BASE_URL
? `${process.env.E2E_BASE_URL}/api/v1`
: 'http://localhost:8080/api/v1';
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
import { DEMO_ANALYST } from './support/env';
/**
* End-to-end regression test for the bug reported against the running app:
* ticking one value in a facet (e.g. "Job family") made every other value in
* that same checkbox list disappear, because the backend's aggregations were
* computed on the already-filtered result set. Fixed in
* apps/search/backends/{elastic,postgres}.py (self-exclusion aggregations)
* and covered at the unit/API level by test_query_builder.py and
* test_search_api.py. This spec drives the real browser against the real,
* Elasticsearch-backed stack (`docker compose up`) end to end, so it also
* catches anything a backend-only test cannot: wiring in FacetGroup/
* FilterPanel, the network round trip, and URL state.
*/
async function signIn(page: import('@playwright/test').Page) {
await page.goto('/login');
await page.getByLabel('Email').fill(DEMO_ANALYST.email);
await page.getByLabel('Password').fill(DEMO_ANALYST.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('button', { name: /^Job family/ })).toBeVisible();
}
/** The raw facet value lives in the label's `title` attribute (see FacetGroup.tsx). */
async function facetValues(group: import('@playwright/test').Locator): Promise<string[]> {
const labels = group.locator('.facet__label');
const count = await labels.count();
const values: string[] = [];
for (let i = 0; i < count; i += 1) {
values.push((await labels.nth(i).getAttribute('title')) ?? '');
}
return values;
}
test.describe('multi-select facets', () => {
test('ticking one job-family value keeps every sibling value in the list', async ({ page }) => {
await signIn(page);
const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) });
const before = await facetValues(jobFamily);
expect(before.length).toBeGreaterThan(1);
const firstCheckbox = jobFamily.getByRole('checkbox').first();
const firstValue = await jobFamily.locator('.facet__label').first().getAttribute('title');
const responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
await firstCheckbox.check();
await responsePromise;
await expect(firstCheckbox).toBeChecked();
// The exact bug: this used to collapse to just the ticked value.
const after = await facetValues(jobFamily);
expect(after.sort()).toEqual(before.sort());
// The URL is the source of truth for the filter state.
expect(page.url()).toContain(`roles=${encodeURIComponent(firstValue ?? '')}`);
});
test('ticking a second job-family value accumulates instead of replacing the first', async ({ page }) => {
await signIn(page);
const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) });
const checkboxes = jobFamily.getByRole('checkbox');
const labels = jobFamily.locator('.facet__label');
const firstValue = await labels.nth(0).getAttribute('title');
const secondValue = await labels.nth(1).getAttribute('title');
let responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
await checkboxes.nth(0).check();
await responsePromise;
responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
await checkboxes.nth(1).check();
await responsePromise;
await expect(checkboxes.nth(0)).toBeChecked();
await expect(checkboxes.nth(1)).toBeChecked();
const url = new URL(page.url());
const roles = (url.searchParams.get('roles') ?? '').split(',');
expect(roles).toContain(firstValue);
expect(roles).toContain(secondValue);
// Ticking the second one must not have removed the first from the DOM.
const after = await facetValues(jobFamily);
expect(after).toContain(firstValue);
expect(after).toContain(secondValue);
// "Clear all" must be able to undo both selections in one click.
await page.getByRole('button', { name: /clear all/i }).click();
await expect(checkboxes.nth(0)).not.toBeChecked();
});
test('a different facet still narrows when job family is filtered (no over-relaxing)', async ({ page }) => {
await signIn(page);
const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) });
const country = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Country/ }) });
// Country is collapsed by default (only the first three facets start
// open) - expand it so its checkbox list is actually in the DOM.
await page.getByRole('button', { name: /^Country/ }).click();
const countryBefore = await facetValues(country);
expect(countryBefore.length).toBeGreaterThan(0);
const responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
await jobFamily.getByRole('checkbox').first().check();
await responsePromise;
const countryAfter = await facetValues(country);
// A facet with no selection of its own must still be scoped by the
// active filter - it should not also stay untouched.
expect(countryAfter.length).toBeLessThanOrEqual(countryBefore.length);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
/**
* Reads the demo analyst credentials straight out of the repo's `.env`
* (created by `make env` from `.env.example`) instead of hard-coding them, so
* the e2e suite keeps working if the seeded demo password ever changes.
*/
function readDotEnv(): Record<string, string> {
const path = fileURLToPath(new URL('../../../.env', import.meta.url));
const text = readFileSync(path, 'utf-8');
const values: Record<string, string> = {};
for (const line of text.split('\n')) {
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
const key = match?.[1];
if (key !== undefined) values[key] = match?.[2] ?? '';
}
return values;
}
const env = readDotEnv();
export const DEMO_ANALYST = {
email: env.DEMO_ANALYST_EMAIL ?? 'analyst@example.com',
password: env.DEMO_ANALYST_PASSWORD ?? 'Analyst-Passw0rd!',
};
+48 -23
View File
@@ -4,7 +4,21 @@
# Putting both behind one origin means the browser never issues a cross-origin # Putting both behind one origin means the browser never issues a cross-origin
# request, so no CORS pre-flight, no third-party cookie rules, and the API can # request, so no CORS pre-flight, no third-party cookie rules, and the API can
# keep its allow-list empty in production. # keep its allow-list empty in production.
#
# Every location includes security-headers.conf rather than relying on a
# server-level declaration - see the comment in that file for why inheritance
# cannot be trusted here. Proxied locations additionally include
# proxy-backend.conf, which forwards the client details and drops the
# duplicate security headers Django emits.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# The SPA's policy: no inline script, nothing loaded off-origin. The bundle is
# fully self-hosted, so this can stay strict. ('unsafe-inline' for styles is
# still required by the inline style attributes React emits.)
map $host $spa_csp {
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
}
server { server {
listen 8080; listen 8080;
server_name _; server_name _;
@@ -12,14 +26,6 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# --- Security headers ----------------------------------------------------
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
# The bundle is fully self-hosted, so the policy can stay strict.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
server_tokens off; server_tokens off;
client_max_body_size 2m; client_max_body_size 2m;
@@ -29,40 +35,59 @@ server {
location = /healthz { location = /healthz {
access_log off; access_log off;
include /etc/nginx/conf.d/security-headers.conf;
add_header Content-Type text/plain; add_header Content-Type text/plain;
return 200 'ok'; return 200 'ok';
} }
# Hashed assets never change under the same name - cache them hard. # Hashed assets never change under the same name - cache them hard.
location /assets/ { location /assets/ {
include /etc/nginx/conf.d/security-headers.conf;
add_header Content-Security-Policy $spa_csp always;
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
try_files $uri =404; try_files $uri =404;
} }
location /api/ { # --- API documentation ---------------------------------------------------
# Swagger UI and ReDoc bootstrap themselves from an inline <script> block
# that drf-spectacular generates, which the SPA's "script-src 'self'"
# blocks - the page loads but renders blank. Rather than punching
# 'unsafe-inline' into the policy the SPA runs under, the exemption is
# confined to these three documentation paths: they render no user data and
# no user input, they load nothing off-origin (drf-spectacular-sidecar
# serves the bundles from /static/, not a CDN), and in production they are
# not routed at all - SERVE_API_DOCS defaults to false there.
location ~ ^/api/(docs|redoc|schema)/ {
include /etc/nginx/conf.d/security-headers.conf;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
include /etc/nginx/conf.d/proxy-backend.conf;
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
} }
# Django admin and API documentation share the backend origin. location /api/ {
location ~ ^/(admin|static)/ { include /etc/nginx/conf.d/security-headers.conf;
add_header Content-Security-Policy $spa_csp always;
include /etc/nginx/conf.d/proxy-backend.conf;
proxy_pass http://backend:8000;
}
# Django admin and static files share the backend origin.
location ~ ^/(admin|static)/ {
include /etc/nginx/conf.d/security-headers.conf;
add_header Content-Security-Policy $spa_csp always;
include /etc/nginx/conf.d/proxy-backend.conf;
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
# Client-side routing: unknown paths fall back to the SPA shell. # Client-side routing: unknown paths fall back to the SPA shell.
location / { location / {
try_files $uri $uri/ /index.html; include /etc/nginx/conf.d/security-headers.conf;
add_header Content-Security-Policy $spa_csp always;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
} }
} }
+1225 -1
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -9,7 +9,12 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src --max-warnings 0", "lint": "eslint src --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:contract": "vitest run --config vitest.contract.config.ts"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
@@ -20,17 +25,25 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.12.0", "@eslint/js": "^9.12.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"@vitest/ui": "^2.1.9",
"eslint": "^9.12.0", "eslint": "^9.12.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.11.0", "globals": "^15.11.0",
"jsdom": "^25.0.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"typescript-eslint": "^8.8.1", "typescript-eslint": "^8.8.1",
"vite": "^5.4.8" "vite": "^5.4.8",
"vitest": "^2.1.9",
"zod": "^3.25.76"
} }
} }
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Runs against the already-running docker compose stack (`make up` /
* `make dev`), not a spawned dev server: the search results these tests
* assert on come from the real Postgres/Elasticsearch-backed API, which is
* the whole point of an end-to-end test - a mocked backend would not have
* caught the facet bug this suite guards against.
*
* npm run test:e2e
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8080',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
+26
View File
@@ -0,0 +1,26 @@
# -----------------------------------------------------------------------------
# Shared configuration for every location proxied to the Django container.
#
# Django's SecurityMiddleware sets its own X-Frame-Options, nosniff,
# Referrer-Policy and Cross-Origin-Opener-Policy, and nginx adds the same
# names from security-headers.conf - so without the proxy_hide_header lines
# below the client receives each one twice. That is not merely untidy: on
# /api/docs/ drf-spectacular relaxes COOP to "unsafe-none" while the edge
# sends "same-origin", and a response carrying two contradictory
# Cross-Origin-Opener-Policy values is one the browser is free to resolve
# either way. The edge is the single authority; the upstream copies are
# dropped here.
# -----------------------------------------------------------------------------
proxy_hide_header X-Frame-Options;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Referrer-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
+19
View File
@@ -0,0 +1,19 @@
# -----------------------------------------------------------------------------
# Security headers, kept in one file and `include`d into every location.
#
# Why an include rather than declaring these once at server level: nginx's
# add_header is *replacing*, not additive - the moment a location block
# declares any add_header of its own (a Cache-Control, say), every header
# inherited from the enclosing server block is silently dropped. That is easy
# to miss precisely because nothing fails: the page still loads, just without
# its protections. Including the same snippet everywhere makes each location
# self-contained, so adding a Cache-Control to a block can no longer disarm it.
#
# Note there is deliberately no Content-Security-Policy here: the SPA and the
# API documentation need different policies, so each location sets its own.
# -----------------------------------------------------------------------------
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
+122
View File
@@ -0,0 +1,122 @@
/**
* `FacetGroup` renders one facet's checkbox list. These tests cover the
* multi-select contract from the rendering side (complementing
* `useSearchFilters.test.tsx`, which covers the state side): several boxes
* can be checked at once, checking one must not affect its siblings, and a
* value that is selected but has since dropped out of the current bucket
* list must stay visible so the user can still untick it.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { FacetGroup } from '@/components/FacetGroup';
import type { FacetBucket } from '@/types';
const BUCKETS: FacetBucket[] = [
{ key: 'engineering', count: 42 },
{ key: 'sales', count: 30 },
{ key: 'human_resources', count: 12 },
];
describe('FacetGroup', () => {
it('renders one checkbox per bucket, none checked by default', () => {
render(
<FacetGroup title="Job family" buckets={BUCKETS} selected={[]} onToggle={vi.fn()} />,
);
for (const bucket of BUCKETS) {
const checkbox = screen.getByRole('checkbox', { name: new RegExp(bucket.key) });
expect(checkbox).not.toBeChecked();
}
});
it('checks every value present in `selected`, independently of the others', () => {
render(
<FacetGroup
title="Job family"
buckets={BUCKETS}
selected={['engineering', 'human_resources']}
onToggle={vi.fn()}
/>,
);
expect(screen.getByRole('checkbox', { name: /engineering/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /human_resources/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /^sales/ })).not.toBeChecked();
});
it('calls onToggle with the clicked value only, leaving other selections to the caller', async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
render(
<FacetGroup title="Job family" buckets={BUCKETS} selected={['engineering']} onToggle={onToggle} />,
);
await user.click(screen.getByRole('checkbox', { name: /^sales/ }));
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onToggle).toHaveBeenCalledWith('sales');
});
it('clicking a second checkbox after the first stays independent (two separate calls, two separate values)', async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
render(
<FacetGroup title="Job family" buckets={BUCKETS} selected={[]} onToggle={onToggle} />,
);
await user.click(screen.getByRole('checkbox', { name: /engineering/ }));
await user.click(screen.getByRole('checkbox', { name: /^sales/ }));
expect(onToggle).toHaveBeenNthCalledWith(1, 'engineering');
expect(onToggle).toHaveBeenNthCalledWith(2, 'sales');
});
it('keeps a selected value visible even after it drops out of the bucket list', () => {
// Mirrors what the backend now guarantees for a facet's *own* filter,
// and is still needed for facets narrowed by a *different* filter: the
// checkbox for an already-ticked value must never vanish, or the user
// would have no way to untick it.
render(
<FacetGroup
title="Job family"
buckets={[{ key: 'sales', count: 5 }]}
selected={['engineering']}
onToggle={vi.fn()}
/>,
);
const stale = screen.getByRole('checkbox', { name: /engineering/ });
expect(stale).toBeChecked();
});
it('a searchable facet filters its own list without touching the selection', async () => {
const manyBuckets: FacetBucket[] = [
...BUCKETS,
{ key: 'marketing', count: 8 },
{ key: 'finance', count: 7 },
{ key: 'legal', count: 6 },
{ key: 'design', count: 5 },
{ key: 'support', count: 4 },
{ key: 'operations', count: 3 },
];
const user = userEvent.setup();
render(
<FacetGroup
title="Job family"
buckets={manyBuckets}
selected={['engineering']}
onToggle={vi.fn()}
searchable
/>,
);
await user.type(screen.getByPlaceholderText(/filter job family/i), 'sal');
expect(screen.getByRole('checkbox', { name: /^sales/ })).toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: /marketing/ })).not.toBeInTheDocument();
// The active selection must survive the local filter text too.
expect(screen.getByRole('checkbox', { name: /engineering/ })).toBeChecked();
});
});
@@ -0,0 +1,121 @@
/**
* `FilterPanel` wires six independent `FacetGroup`s to one `filters` object.
* The regression this guards against is cross-talk between facets: selecting
* a value in "Skills" must call `onToggle` with the `skills` key, never
* `roles` or any other facet, and each group must show only its own
* selections.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { FilterPanel } from '@/components/FilterPanel';
import { EMPTY_FILTERS } from '@/types';
import type { FacetBucket, FacetName } from '@/types';
const FACETS: Record<FacetName, FacetBucket[]> = {
skills: [{ key: 'python', count: 10 }, { key: 'react', count: 8 }],
job_title_role: [{ key: 'engineering', count: 12 }, { key: 'sales', count: 6 }],
seniority: [{ key: 'senior', count: 9 }],
country: [{ key: 'germany', count: 5 }, { key: 'spain', count: 4 }],
industry: [{ key: 'software', count: 7 }],
company: [{ key: 'acme corp', count: 3 }],
};
describe('FilterPanel', () => {
it('toggling a value in one facet reports that facet key, not a sibling one', async () => {
// Skills and Job family are both open by default (the first three
// groups start expanded); Country only auto-opens once it has an active
// selection, so this test sticks to the always-open pair.
const onToggle = vi.fn();
const user = userEvent.setup();
render(
<FilterPanel
facets={FACETS}
filters={EMPTY_FILTERS}
activeCount={0}
onToggle={onToggle}
onUpdate={vi.fn()}
onReset={vi.fn()}
/>,
);
await user.click(screen.getByRole('checkbox', { name: /engineering/ }));
expect(onToggle).toHaveBeenCalledWith('roles', 'engineering');
await user.click(screen.getByRole('checkbox', { name: /python/ }));
expect(onToggle).toHaveBeenCalledWith('skills', 'python');
});
it('each facet only shows its own active selections', () => {
render(
<FilterPanel
facets={FACETS}
filters={{ ...EMPTY_FILTERS, roles: ['engineering'], countries: ['germany', 'spain'] }}
activeCount={3}
onToggle={vi.fn()}
onUpdate={vi.fn()}
onReset={vi.fn()}
/>,
);
expect(screen.getByRole('checkbox', { name: /engineering/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /^sales/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /germany/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /spain/ })).toBeChecked();
// Skills has no active selection, even though other facets do.
expect(screen.getByRole('checkbox', { name: /python/ })).not.toBeChecked();
});
it('shows the active filter count and clears everything on "Clear all"', async () => {
const onReset = vi.fn();
const user = userEvent.setup();
render(
<FilterPanel
facets={FACETS}
filters={{ ...EMPTY_FILTERS, roles: ['engineering', 'sales'] }}
activeCount={2}
onToggle={vi.fn()}
onUpdate={vi.fn()}
onReset={onReset}
/>,
);
const clearButton = screen.getByRole('button', { name: /clear all \(2\)/i });
await user.click(clearButton);
expect(onReset).toHaveBeenCalledTimes(1);
});
it('does not show a clear button when nothing is active', () => {
render(
<FilterPanel
facets={FACETS}
filters={EMPTY_FILTERS}
activeCount={0}
onToggle={vi.fn()}
onUpdate={vi.fn()}
onReset={vi.fn()}
/>,
);
expect(screen.queryByRole('button', { name: /clear all/i })).not.toBeInTheDocument();
});
it('typing an experience bound calls onUpdate with just that field', async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
render(
<FilterPanel
facets={FACETS}
filters={EMPTY_FILTERS}
activeCount={0}
onToggle={vi.fn()}
onUpdate={onUpdate}
onReset={vi.fn()}
/>,
);
await user.type(screen.getByLabelText(/minimum years of experience/i), '5');
const lastCall = onUpdate.mock.calls.at(-1)?.[0];
expect(lastCall).toEqual({ minExperience: 5 });
});
});
@@ -0,0 +1,128 @@
/**
* `useSearchFilters` owns the multi-select state for every facet (skills,
* roles, seniority, countries, industries, companies) via `toggle`. These
* tests pin down the add/remove/OR-within-a-facet contract independently of
* any rendering, since a regression here would silently break every facet at
* once.
*/
import { act, renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { useSearchFilters } from '@/hooks/useSearchFilters';
function wrapper({ children }: { children: ReactNode }) {
return <MemoryRouter initialEntries={['/']}>{children}</MemoryRouter>;
}
describe('useSearchFilters - multi-select toggle', () => {
it('starts with every list filter empty', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
expect(result.current.filters.roles).toEqual([]);
expect(result.current.activeCount).toBe(0);
});
it('adds the first value picked in a facet', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.toggle('roles', 'engineering'));
expect(result.current.filters.roles).toEqual(['engineering']);
expect(result.current.activeCount).toBe(1);
});
it('keeps the first value when a second one is ticked in the same facet', () => {
// This is the exact behaviour the user asked for: ticking "job family"
// must accumulate selections, not replace the previous one.
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.toggle('roles', 'engineering'));
act(() => result.current.toggle('roles', 'sales'));
expect(result.current.filters.roles).toEqual(['engineering', 'sales']);
expect(result.current.activeCount).toBe(2);
});
it('accumulates three or more values in one facet', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.toggle('skills', 'python'));
act(() => result.current.toggle('skills', 'django'));
act(() => result.current.toggle('skills', 'postgresql'));
expect(result.current.filters.skills).toEqual(['python', 'django', 'postgresql']);
});
it('un-ticking a value removes only that value, keeping its siblings', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.toggle('roles', 'engineering'));
act(() => result.current.toggle('roles', 'sales'));
act(() => result.current.toggle('roles', 'human_resources'));
act(() => result.current.toggle('roles', 'sales'));
expect(result.current.filters.roles).toEqual(['engineering', 'human_resources']);
});
it('keeps independent facets independent', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.toggle('roles', 'engineering'));
act(() => result.current.toggle('countries', 'germany'));
act(() => result.current.toggle('countries', 'spain'));
expect(result.current.filters.roles).toEqual(['engineering']);
expect(result.current.filters.countries).toEqual(['germany', 'spain']);
expect(result.current.activeCount).toBe(3);
});
it('resets the page to 1 whenever a filter changes', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.update({ page: 4 }));
expect(result.current.filters.page).toBe(4);
act(() => result.current.toggle('roles', 'engineering'));
expect(result.current.filters.page).toBe(1);
});
it('reset() clears every filter back to defaults', () => {
const { result } = renderHook(() => useSearchFilters(), { wrapper });
act(() => result.current.toggle('roles', 'engineering'));
act(() => result.current.toggle('skills', 'python'));
act(() => result.current.update({ minExperience: 5 }));
act(() => result.current.reset());
expect(result.current.filters).toEqual(result.current.defaults);
expect(result.current.activeCount).toBe(0);
});
});
function useFiltersWithLocation() {
return { ...useSearchFilters(), search: useLocation().search };
}
describe('useSearchFilters - URL round-trip', () => {
it('serialises multiple selections in the same facet as one comma-joined param, and re-hydrates identically from that exact URL', () => {
const { result } = renderHook(() => useFiltersWithLocation(), { wrapper });
act(() => result.current.toggle('roles', 'engineering'));
act(() => result.current.toggle('roles', 'sales'));
const producedSearch = result.current.search;
expect(producedSearch).toContain('roles=engineering%2Csales');
// A page refresh or a shared link starts a brand new hook instance with
// nothing but that URL - re-reading through one proves the query string
// itself carries both selections, not just the in-memory state.
const { result: reread } = renderHook(() => useSearchFilters(), {
wrapper: ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[`/${producedSearch}`]}>{children}</MemoryRouter>
),
});
expect(reread.current.filters.roles).toEqual(['engineering', 'sales']);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
// vitest.config.ts intentionally leaves `test.globals` off (test files import
// describe/it/expect explicitly), so Testing Library's own auto-cleanup -
// which only registers itself when it detects a *global* `afterEach` - never
// fires on its own. Without this, unmounted DOM from one `it()` block leaks
// into the next within the same file, and every query starts matching
// duplicate nodes.
afterEach(() => {
cleanup();
});
+9 -1
View File
@@ -19,5 +19,13 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "@/*": ["./src/*"] } "paths": { "@/*": ["./src/*"] }
}, },
"include": ["src", "vite.config.ts"] "include": [
"src",
"vite.config.ts",
"vitest.config.ts",
"vitest.contract.config.ts",
"playwright.config.ts",
"e2e",
"contract"
]
} }
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/components/FacetGroup.tsx","./src/components/FilterPanel.tsx","./src/components/Highlight.tsx","./src/components/Pagination.tsx","./src/components/ProfileDrawer.tsx","./src/components/ResultCard.tsx","./src/components/SearchBar.tsx","./src/hooks/useDebouncedValue.ts","./src/hooks/useSearchFilters.ts","./src/lib/auth-context.ts","./src/lib/auth.tsx","./src/pages/LoginPage.tsx","./src/pages/SearchPage.tsx","./src/types/index.ts","./vite.config.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/components/FacetGroup.test.tsx","./src/components/FacetGroup.tsx","./src/components/FilterPanel.test.tsx","./src/components/FilterPanel.tsx","./src/components/Highlight.tsx","./src/components/Pagination.tsx","./src/components/ProfileDrawer.tsx","./src/components/ResultCard.tsx","./src/components/SearchBar.tsx","./src/hooks/useDebouncedValue.ts","./src/hooks/useSearchFilters.test.tsx","./src/hooks/useSearchFilters.ts","./src/lib/auth-context.ts","./src/lib/auth.tsx","./src/pages/LoginPage.tsx","./src/pages/SearchPage.tsx","./src/test/setup.ts","./src/types/index.ts","./vite.config.ts","./vitest.config.ts","./vitest.contract.config.ts","./playwright.config.ts","./e2e/multi-select-facets.spec.ts","./e2e/support/env.ts","./contract/schemas.ts","./contract/search-response.contract.test.ts","./contract/support/env.ts"],"version":"5.9.3"}
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
/**
* Separate from vite.config.ts on purpose: the app's build config knows
* nothing about a test runner, and vitest's `test` block is not a valid Vite
* option, so mixing them makes `vite build` warn about an unknown key.
*/
export default defineConfig({
plugins: [react()],
resolve: {
// Mirrors the "@/*" path mapping in tsconfig.json.
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: false,
// e2e/ holds Playwright specs (a different `test()` global), and
// contract/ hits the live stack over the network - neither belongs in
// the hermetic default run. See vitest.contract.config.ts for the latter.
exclude: ['node_modules/**', 'e2e/**', 'contract/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/main.tsx', 'src/vite-env.d.ts', 'src/**/*.d.ts'],
},
},
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
/**
* Separate from vitest.config.ts on purpose: contract tests make real HTTP
* calls to the running docker compose stack and must never be picked up by
* a plain `npm test` (which has to stay hermetic and fast, and must pass
* with no services running at all).
*
* npm run test:contract
*/
export default defineConfig({
test: {
environment: 'node',
include: ['contract/**/*.test.ts'],
},
});
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# =============================================================================
# Replaces every "change-me..." placeholder in .env with a freshly generated
# secret.
#
# config/settings/prod.py refuses to start on a placeholder value, so this is
# what makes the very first `make up` both work and be safe: nobody ends up
# running a real deployment on the credentials published in .env.example.
#
# scripts/generate_secrets.sh [path-to-env] (default: .env)
#
# Only placeholders are touched - a value you have already set is left alone,
# so re-running this is safe and never invalidates working credentials.
# =============================================================================
set -Eeuo pipefail
ENV_FILE="${1:-.env}"
[[ -f "${ENV_FILE}" ]] || { echo "No ${ENV_FILE} to update." >&2; exit 1; }
# `openssl rand -base64` is available anywhere Docker is; tr strips the
# characters that would need quoting inside a URL-shaped DATABASE_URL.
random() { openssl rand -base64 48 | tr -d '\n=+/' | cut -c1-"${1:-50}"; }
changed=0
for key in DJANGO_SECRET_KEY POSTGRES_PASSWORD ELASTIC_PASSWORD; do
current="$(grep -E "^${key}=" "${ENV_FILE}" | head -1 | cut -d= -f2- || true)"
case "${current,,}" in
*change-me*|*changeme*|'')
# 64 chars for the signing key (prod.py enforces >= 50), 32 for the
# service passwords.
length=32
[[ "${key}" == "DJANGO_SECRET_KEY" ]] && length=64
value="$(random "${length}")"
# `|` as the sed delimiter: generated values never contain it.
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
echo " generated ${key}"
changed=1
;;
*)
echo " kept ${key} (already set)"
;;
esac
done
if [[ "${changed}" -eq 1 ]]; then
echo "Secrets written to ${ENV_FILE}. It is gitignored - back it up somewhere safe."
fi