start project
This commit is contained in:
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}]
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}}
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1225
-1
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] } }],
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user