Publish curated VacatureRadar source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:19:10 +02:00
commit e29774cb7f
338 files changed
+32699

No files matched your search

View File
Whitespace-only changes.
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import pytest
import scripts.benchmark as benchmark
def test_load_benchmark_dataset():
dataset = benchmark.load_benchmark_dataset(Path("fixtures/benchmark/quality_benchmark.json"))
assert dataset["dataset_version"] == "vr116-2026-07-21"
assert isinstance(dataset["parser_coverage"], list)
assert isinstance(dataset["dedupe"], dict)
def test_percentile_helper_is_deterministic():
assert benchmark._percentile([1.0, 2.0, 3.0, 4.0], 50) == 2.0
assert benchmark._percentile([], 95) == 0.0
@pytest.mark.django_db
def test_run_benchmark_report_smoke(db):
report = benchmark.run_benchmark_report(
Path("fixtures/benchmark/quality_benchmark.json"),
quick=True,
top_n=20,
jobs=25,
skip_performance=True,
)
assert report["status"] in {"passed", "failed"}
assert report["jobs_processed"] == 25
assert report["top_n"] == 20
assert report["performance"]["status"] == "skipped"
assert report["nfr_007"]["measured"] is False
assert set(report["hardware"].keys()) >= {"platform", "python", "cpu_count"}
assert report["parser"]["coverage_ratio"] >= 0
assert report["ranking"]["top_n"] == 20
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import pytest
from django.contrib.auth import get_user_model
from django.utils import timezone
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourcePolicyReview, SourceRobotsCache
@pytest.fixture
def user(db):
return get_user_model().objects.create_user(
username="jens",
email="jens@example.invalid",
password="correct-horse-battery-staple",
)
@pytest.fixture
def profile(user):
return SearchProfile.objects.create(
user=user,
name="Testprofiel",
is_active=True,
home_municipality="Hasselt",
home_latitude=Decimal("50.930700"),
home_longitude=Decimal("5.332500"),
max_distance_km=50,
learning_enabled=True,
desired_titles=["infrastructure engineer", "systeembeheerder"],
excluded_titles=["sales", "recruiter"],
desired_skills=["Microsoft 365", "VMware"],
allowed_employment_types=["full_time", "permanent"],
preferred_workplace=["hybrid", "on_site"],
)
@pytest.fixture
def source(db):
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
SourcePolicyReview.objects.create(
source=source,
decision=SourcePolicyReview.Decision.ALLOW,
reason="Testbron expliciet toegestaan",
expires_at=timezone.now() + timedelta(days=1),
)
SourceRobotsCache.objects.create(
origin="https://jobs.example.org",
expires_at=timezone.now() + timedelta(days=1),
allow_rules={"*": ["/"]},
disallow_rules={},
)
return source
@pytest.fixture
def employer(db):
from apps.jobs.models import Employer
return Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
is_direct_employer=True,
is_recruiter=False,
confidence=0.9,
)
@pytest.fixture
def job(db, employer):
from apps.jobs.models import JobPosting
return JobPosting.objects.create(
employer=employer,
original_title="Infrastructure Engineer",
normalized_title="infrastructure engineer",
job_family="infrastructure",
canonical_url="https://jobs.example.org/vacatures/infrastructure-engineer",
canonical_key="c" * 64,
content_hash="d" * 64,
description_text=(
"Beheer Microsoft 365 en VMware. Hybride werk met beperkte tweedelijnssupport."
),
raw_location="Hasselt, Limburg",
region="Limburg",
municipality="Hasselt",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time", "permanent"],
skills_required=["Microsoft 365"],
skills_preferred=["VMware"],
direct_employer=True,
recruiter=False,
extraction_confidence=0.9,
analysis_features={
"support_ratio": 0.1,
"public_sector_signal": 0.0,
"experience_years_max": 3,
},
status=JobPosting.Status.ACTIVE,
)
@@ -0,0 +1,298 @@
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import ScoreRun
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - optional dependency
sync_playwright = None
def _seed_dashboard_score(profile, job) -> None:
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=Decimal("88.2"),
confidence=Decimal("0.76"),
recommendation=ScoreRun.Recommendation.STRONG,
components={"content": 42.0, "skills": 23.0, "location": 23.2},
positives=["Relevante locatie", "Ervaring met ops"],
concerns=[],
hard_exclusions=[],
evidence={"tests": ["vr-114"]},
model_version="vr-114",
prompt_version="1.0.0",
)
def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) -> None:
html = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else payload
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all("h1")
assert headings, f"{page_name}: ontbreekt h1"
assert len(headings) == 1, f"{page_name}: verwacht exact één h1, gevonden {len(headings)}"
assert soup.html is not None
assert soup.html.get("lang"), f"{page_name}: <html> mist lang-attribuut"
for field in soup.find_all(["input", "textarea", "select"]):
if field.get("type") == "hidden" or field.get("name") == "csrfmiddlewaretoken":
continue
field_id = field.get("id")
has_label = bool(field.find_parent("label"))
has_label = has_label or bool(field.get("aria-label") or field.get("aria-labelledby"))
has_label = has_label or (
field_id is not None and bool(soup.find("label", attrs={"for": field_id}))
)
assert has_label, (
f"{page_name}: formulierveld zonder label of aria-voorziening: "
f"{field.get('name') or field.get('type')}"
)
for button in soup.find_all("button"):
label = button.get_text(" ", strip=True) or button.get("aria-label")
assert label, f"{page_name}: knop zonder tekst of aria-label"
for element in soup.select("script[src], link[rel='stylesheet'][href]"):
asset_url = element.get("src") or element.get("href") or ""
assert not asset_url.startswith(("http://", "https://")), (
f"{page_name}: externe productie-asset gevonden: {asset_url}"
)
def _run_html_accessibility_probe(client, user, profile, job) -> None:
_seed_dashboard_score(profile=profile, job=job)
response = client.get(reverse("login"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="login")
client.force_login(user)
response = client.get(reverse("dashboard:today"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="dashboard")
assert "Zoek vacatures" in response.content.decode("utf-8")
response = client.get(reverse("jobs:list"), {"q": job.original_title})
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="jobs-list")
jobs_body = response.content.decode("utf-8")
assert "1 vacatures gevonden" in jobs_body
assert "88%" in jobs_body
response = client.get(reverse("jobs:detail", kwargs={"pk": job.pk}))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="jobs-detail")
response = client.get(reverse("profiles:list"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="profiles-list")
profile_body = response.content.decode("utf-8")
assert 'class="radar-illustration"' in profile_body
assert 'aria-hidden="true"' in profile_body
assert "data-radar-illustration" in profile_body
premium_css = (Path(__file__).resolve().parents[2] / "static/css/premium.css").read_text(
encoding="utf-8"
)
app_js = (Path(__file__).resolve().parents[2] / "static/js/app.js").read_text(encoding="utf-8")
assert "prefers-reduced-motion: reduce" in premium_css
assert 'matchMedia("(prefers-reduced-motion: reduce)")' in app_js
response = client.get(reverse("profiles:edit", kwargs={"pk": profile.pk}))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="profiles-edit")
response = client.get(reverse("jobs:applications"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="applications")
assert "Sollicitatiepipeline" in response.content.decode("utf-8")
response = client.get(reverse("sources:list"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="sources")
response = client.get(reverse("dashboard:system"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="system")
def _assert_playwright_accessibility(page, *, expected_h1: str) -> None:
page.wait_for_load_state("networkidle")
assert page.get_by_role("heading", level=1).count() == 1
headings = page.get_by_role("heading", level=1).all_text_contents()
assert any(expected_h1 in heading for heading in headings)
page.keyboard.press("Tab")
focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()")
assert focused_tag != "body"
focus_indicator = page.evaluate(
"""() => {
const style = getComputedStyle(document.activeElement);
return {outline: style.outlineStyle, shadow: style.boxShadow};
}"""
)
assert focus_indicator["outline"] != "none" or focus_indicator["shadow"] != "none"
viewport_width = page.evaluate("window.innerWidth")
document_width = page.evaluate("document.documentElement.scrollWidth")
assert document_width <= viewport_width + 1
def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None:
del tmp_path
directory = Path(__file__).resolve().parents[2] / "artifacts" / "browser-tests"
directory.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(directory / f"vr114_e2e_failure_{viewport_name}.png"), full_page=True)
@pytest.mark.integration
@pytest.mark.django_db
def test_vr114_html_a11y_probe(client, user, profile, job):
_run_html_accessibility_probe(client, user, profile, job)
@pytest.mark.integration
@pytest.mark.django_db
@pytest.mark.parametrize(
"viewport",
[
{"name": "mobile-390", "width": 390, "height": 844},
{"name": "tablet-768", "width": 768, "height": 1024},
{"name": "laptop-1280", "width": 1280, "height": 800},
{"name": "desktop-1920", "width": 1920, "height": 1080},
{"name": "wide-2560", "width": 2560, "height": 1440},
{"name": "ultrawide-3440", "width": 3440, "height": 1200},
],
)
def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profile, job):
_seed_dashboard_score(profile=profile, job=job)
if sync_playwright is None:
pytest.fail("Playwright is verplicht voor de E2E- en accessibilitygate.")
_run_html_accessibility_probe(client, user, profile, job)
pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.")
base = live_server.url
dashboard_url = f"{base}{reverse('dashboard:today')}"
login_url = f"{base}{reverse('login')}"
jobs_url = f"{base}{reverse('jobs:list')}"
detail_url = f"{base}{reverse('jobs:detail', kwargs={'pk': job.pk})}"
profile_list_url = f"{base}{reverse('profiles:list')}"
applications_url = f"{base}{reverse('jobs:applications')}"
sources_url = f"{base}{reverse('sources:list')}"
with sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(headless=True)
except Exception as exc: # pragma: no cover - env-dependent
pytest.fail(f"Playwright Chromium is verplicht maar niet beschikbaar: {exc}")
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
context = browser.new_context(
viewport={"width": viewport["width"], "height": viewport["height"]},
reduced_motion="reduce",
)
page = context.new_page()
console_errors: list[str] = []
page_errors: list[str] = []
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
try:
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
page.goto(login_url)
_assert_playwright_accessibility(page, expected_h1="Inloggen")
page.get_by_label("E-mailadres").fill(user.email)
page.get_by_role("textbox", name="Wachtwoord", exact=True).fill(
"correct-horse-battery-staple"
)
page.get_by_role("button", name="Inloggen").click()
page.wait_for_url(dashboard_url)
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
# Dashboard -> filter -> detail.
page.goto(jobs_url)
page.get_by_label("Zoeken", exact=True).fill(job.original_title)
page.get_by_role("button", name="Filter", exact=True).click()
assert page.url.startswith(jobs_url)
page.get_by_role("link", name="Volledige analyse").click()
page.wait_for_url(detail_url)
_assert_playwright_accessibility(page, expected_h1=job.original_title)
# Vier feedbackacties.
page.get_by_role("button", name="Interessant").click()
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Bewaren").click()
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Verbergen").click()
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Dossier starten").click()
page.wait_for_selector(".toast.success")
# Profiel aanpassen.
page.goto(profile_list_url)
page.get_by_role("link", name="Radar configureren").click()
page.get_by_label("Infrastructure engineer").check()
page.get_by_label("System engineer").check()
page.get_by_role("button", name="Instellingen opslaan").click()
page.wait_for_url(profile_list_url)
# Sollicitatie dossier openen en bijwerken.
page.goto(applications_url)
page.get_by_role("link", name="Open dossier").click()
page.locator("select[name='status']").select_option("interview")
page.locator("input[name='contact_name']").fill("Sofie Janssens")
page.locator("input[name='contact_email']").fill("sofia@example.invalid")
page.locator("input[name='follow_up_date']").fill(
(timezone.localdate() + timedelta(days=7)).isoformat()
)
page.locator("textarea[name='notes']").fill("Eerste follow-up ingepland.")
page.get_by_role("button", name="Dossier opslaan").click()
page.wait_for_url(applications_url)
# Handmatige import via plaktekst.
page.goto(sources_url)
page.locator(".import-disclosure > summary").click()
page.locator("textarea[name='pasted_text']").fill(
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
)
page.get_by_role("button", name="Import uitvoeren").click()
page.wait_for_selector("text=Importresultaat")
assert page.get_by_text("Modus: paste").is_visible()
assert page.get_by_text("Herkenbare items").is_visible()
# Keyboardvolgorde en focuseigenschappen controleren.
page.keyboard.press("Tab")
page.keyboard.press("Tab")
page.keyboard.press("Tab")
_assert_playwright_accessibility(page, expected_h1="Scan-endpoints")
assert console_errors == []
assert page_errors == []
except Exception:
_take_failure_screenshot(page, tmp_path, viewport["name"])
raise
finally:
context.close()
browser.close()
+246
View File
@@ -0,0 +1,246 @@
from __future__ import annotations
import csv
import io
import json
import zipfile
from datetime import timedelta
from decimal import Decimal
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import Application, ApplicationTimelineEvent, JobSourceAlias, ScoreRun
from apps.jobs.services.feedback import record_feedback
@pytest.mark.integration
@pytest.mark.django_db
def test_applied_feedback_creates_sanitized_application_snapshot_and_timeline(
job, profile, source, user
):
JobSourceAlias.objects.create(
job=job,
source=source,
url=job.canonical_url,
canonical_url=job.canonical_url,
external_id="ext-applied-1",
source_title="Applied page",
source_employer=job.employer_name,
is_canonical=True,
extraction_method="manual-test",
)
record_feedback(user=user, job=job, action="applied")
application = Application.objects.get(user=user, job=job)
snapshot = application.snapshot
assert snapshot["schema_version"] == "1.0.0"
assert snapshot["snapshot_kind"] == "application"
assert snapshot["title"] == job.original_title
assert snapshot["job"]["id"] == str(job.pk)
assert snapshot["job"]["description_html"] == job.description_html_sanitized
assert snapshot["sources"][0]["url"] == job.canonical_url
assert snapshot["sources"][0]["is_canonical"] is True
assert snapshot["scores"] is None
event_types = set(
ApplicationTimelineEvent.objects.filter(application=application).values_list(
"event_type", flat=True
)
)
assert ApplicationTimelineEvent.EventType.CREATED in event_types
assert ApplicationTimelineEvent.EventType.SNAPSHOT_CAPTURED in event_types
@pytest.mark.integration
@pytest.mark.django_db
def test_application_update_creates_timeline_events_for_status_notes_and_contact(
client, job, profile, source, user
):
record_feedback(user=user, job=job, action="applied")
application = Application.objects.get(user=user, job=job)
client.force_login(user)
response = client.post(
reverse("jobs:application-edit", kwargs={"pk": application.pk}),
{
"status": Application.Status.INTERVIEW,
"contact_name": "Sofie Van Hecke",
"contact_email": "recruiter@example.invalid",
"notes": "Eerste follow-up gepland voor vrijdag.",
},
)
assert response.status_code == 302
application.refresh_from_db()
assert application.status == Application.Status.INTERVIEW
assert application.contact_name == "Sofie Van Hecke"
assert application.contact_email == "recruiter@example.invalid"
event_types = set(
ApplicationTimelineEvent.objects.filter(application=application).values_list(
"event_type", flat=True
)
)
assert ApplicationTimelineEvent.EventType.STATUS_CHANGED in event_types
assert ApplicationTimelineEvent.EventType.NOTES_UPDATED in event_types
assert ApplicationTimelineEvent.EventType.CONTACT_UPDATED in event_types
@pytest.mark.integration
@pytest.mark.django_db
def test_pipeline_status_post_is_persistent_audited_and_user_scoped(client, job, user):
record_feedback(user=user, job=job, action="applied")
application = Application.objects.get(user=user, job=job)
client.force_login(user)
response = client.post(
reverse("jobs:application-status", kwargs={"pk": application.pk}),
{"status": Application.Status.INTERVIEW},
)
assert response.status_code == 302
application.refresh_from_db()
assert application.status == Application.Status.INTERVIEW
assert ApplicationTimelineEvent.objects.filter(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
metadata__from=Application.Status.APPLIED,
metadata__to=Application.Status.INTERVIEW,
).exists()
other = get_user_model().objects.create_user(username="scoped-other")
other_application = Application.objects.create(user=other, job=job)
assert (
client.post(
reverse("jobs:application-status", kwargs={"pk": other_application.pk}),
{"status": Application.Status.OFFER},
).status_code
== 404
)
@pytest.mark.integration
@pytest.mark.django_db
def test_application_export_generates_sanitized_zip_payload(client, job, source, user, profile):
JobSourceAlias.objects.create(
job=job,
source=source,
url=job.canonical_url,
canonical_url=job.canonical_url,
external_id="ext-export-1",
source_title="Export page",
source_employer=job.employer_name,
is_canonical=True,
extraction_method="manual-test",
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=1,
score=Decimal("84.20"),
confidence=Decimal("0.71"),
recommendation=ScoreRun.Recommendation.POSSIBLE,
components={"content": 0.41},
positives=["python"],
concerns=["travel"],
hard_exclusions=[],
evidence={"notes": "Voorbeeldscore"},
model_version="testsuite",
prompt_version="1.0.0",
)
record_feedback(user=user, job=job, action="applied")
record_feedback(user=user, job=job, action="applied")
application = Application.objects.get(user=user, job=job)
application.notes = "Vaste follow-up notitie."
application.save(update_fields=["notes", "updated_at"])
client.force_login(user)
response = client.get(reverse("jobs:application-export", kwargs={"pk": application.pk}))
assert response.status_code == 200
assert response["Content-Type"] == "application/zip"
assert 'filename="' in response["Content-Disposition"]
filename = response["Content-Disposition"].partition("filename=")[2].strip().strip('"')
assert filename.startswith(f"application-{application.pk}-")
assert filename.endswith(".zip")
assert ".." not in filename and "/" not in filename and "\\" not in filename
archive = zipfile.ZipFile(io.BytesIO(response.content))
members = sorted(archive.namelist())
assert members == ["application.json", "application_print.html", "timeline.csv"]
application_payload = json.loads(archive.read("application.json").decode("utf-8"))
assert application_payload["application"]["id"] == str(application.pk)
assert application_payload["application"]["job_id"] == str(job.pk)
assert application_payload["application"]["status"] == Application.Status.APPLIED
assert application_payload["snapshot"]["scores"]["score"] == 84.2
assert application_payload["snapshot"]["sources"][0]["url"] == job.canonical_url
csv_rows = list(csv.DictReader(io.StringIO(archive.read("timeline.csv").decode("utf-8"))))
assert {row["type"] for row in csv_rows} >= {
ApplicationTimelineEvent.EventType.CREATED,
ApplicationTimelineEvent.EventType.SNAPSHOT_CAPTURED,
}
html = archive.read("application_print.html").decode("utf-8")
assert "<html" in html.lower()
assert "<link " not in html.lower()
assert "<script" not in html.lower()
assert "raw body" not in json.dumps(application_payload)
response_json = client.get(reverse("jobs:application-print", kwargs={"pk": application.pk}))
assert response_json.status_code == 200
assert response_json["Content-Type"] == "text/html; charset=utf-8"
@pytest.mark.integration
@pytest.mark.django_db
def test_application_export_and_delete_are_scoped_and_idempotent(client, job, user, source):
other_user = get_user_model().objects.create_user(
username="other",
email="other@example.invalid",
password="correct-horse-battery-staple",
)
record_feedback(user=user, job=job, action="applied")
owner_application = Application.objects.get(user=user, job=job)
other_application = Application.objects.create(
user=other_user,
job=job,
status=Application.Status.APPLIED,
applied_at=timezone.now() - timedelta(days=1),
follow_up_date=timezone.now().date(),
)
client.force_login(user)
assert (
client.get(
reverse("jobs:application-export", kwargs={"pk": other_application.pk})
).status_code
== 404
)
assert (
client.post(
reverse("jobs:application-delete", kwargs={"pk": other_application.pk})
).status_code
== 404
)
delete_response = client.post(
reverse("jobs:application-delete", kwargs={"pk": owner_application.pk}),
follow=True,
)
assert delete_response.status_code == 200
assert not Application.objects.filter(pk=owner_application.pk).exists()
repeat_delete = client.post(
reverse("jobs:application-delete", kwargs={"pk": owner_application.pk}),
follow=True,
)
assert repeat_delete.status_code == 200
+248
View File
@@ -0,0 +1,248 @@
"""Tests voor het herwerkte loginscherm en de bijbehorende authenticatie-flows."""
from __future__ import annotations
import pytest
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import override_settings
from django.urls import reverse
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_login_page_renders_stitch_design(client):
response = client.get(reverse("login"))
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "<h1>Inloggen</h1>" in body
assert 'name="username"' in body and 'type="email"' in body
assert "Wachtwoord vergeten?" in body
# Local-first: geen externe CDN-assets op het loginscherm.
assert "cdn.tailwindcss.com" not in body
assert "https://fonts.googleapis.com" not in body
assert reverse("password_reset") in body
assert 'src="/static/js/login.js?' in body
assert "<script>" not in body
assert "Persoonlijke intelligence cockpit" in body
assert 'alt="VacatureRadar"' in body
assert "Ontwikkeld door" in body
assert 'aria-label="Ontwikkeld door ITWorx.tech"' in body
assert 'src="/static/img/itworx-wordmark.png" alt=""' in body
assert 'src="/static/img/itworx-wordmark.png"' in body
assert 'href="/static/favicon.svg?' in body
assert 'sizes="any"' in body
@override_settings(VACATURERADAR_OWNER_NAME="Eigen merk")
def test_login_page_keeps_official_brand_separate_from_owner_setting(client):
body = client.get(reverse("login")).content.decode("utf-8")
assert "Eigen merk" not in body
assert 'content="VacatureRadar"' in body
def test_login_with_email(client, user):
response = client.post(
reverse("login"),
{"username": user.email, "password": "correct-horse-battery-staple"},
)
assert response.status_code == 302
assert response.url == reverse("dashboard:today")
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_with_username_still_works(client, user):
response = client.post(
reverse("login"),
{"username": user.username, "password": "correct-horse-battery-staple"},
)
assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_email_case_insensitive(client, user):
response = client.post(
reverse("login"),
{"username": user.email.upper(), "password": "correct-horse-battery-staple"},
)
assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_wrong_password_shows_error(client, user):
response = client.post(
reverse("login"),
{"username": user.email, "password": "verkeerd-wachtwoord"},
)
assert response.status_code == 200
assert "klopt niet" in response.content.decode("utf-8")
def test_password_reset_sends_email(client, user):
response = client.post(reverse("password_reset"), {"email": user.email})
assert response.status_code == 302
assert response.url == reverse("password_reset_done")
assert len(mail.outbox) == 1
message = mail.outbox[0]
assert user.email in message.to
assert "wachtwoord" in message.subject.lower()
assert "/wachtwoord/herstellen/" in message.body
def test_password_reset_unknown_email_is_silent(client):
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
assert response.status_code == 302
assert len(mail.outbox) == 0
def test_demo_button_visible_by_default(client):
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
@override_settings(DEMO_MODE_ENABLED=False)
def test_demo_login_disabled_redirects(client):
response = client.post(reverse("demo-login"))
assert response.status_code == 302
assert response.url == reverse("login")
assert client.session.get("_auth_user_id") is None
@override_settings(DEMO_MODE_ENABLED=False)
def test_demo_button_hidden_when_disabled(client):
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
def test_demo_login_logs_in_and_seeds_environment(client):
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.profiles.models import SearchProfile
response = client.post(reverse("demo-login"))
assert response.status_code == 302
assert response.url == reverse("dashboard:today")
user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
assert not user.has_usable_password()
assert client.session.get("_auth_user_id") == str(user.pk)
# Fictieve, gevulde demo-omgeving.
profile = SearchProfile.objects.get(user=user, is_active=True)
assert JobPosting.objects.count() >= 5
assert ScoreRun.objects.filter(profile=profile).count() >= 5
assert Application.objects.filter(user=user).exists()
# Het gevulde dashboard rendert zonder fouten.
dashboard = client.get(reverse("dashboard:today"))
assert dashboard.status_code == 200
dashboard_html = dashboard.content.decode("utf-8")
assert "Profiel actief" in dashboard_html
assert "Radar actief" not in dashboard_html
assert "Nog geen persoonlijke matches" not in dashboard_html
sources = client.get(reverse("sources:list"))
assert sources.status_code == 200
assert "minstens één import actief" in sources.content.decode("utf-8")
def test_demo_login_is_idempotent(client, monkeypatch):
from datetime import UTC, datetime, timedelta
from django.utils import timezone
from apps.jobs.models import Application, JobPosting
from apps.jobs.services import demo_seed
from apps.profiles.models import SearchProfile
from apps.sources.models import MailboxConnection, Source
# 22:30 UTC is already the following calendar day in Europe/Brussels.
# Freezing this boundary prevents UTC `.date()` calls from creeping back in.
near_local_midnight = datetime(2026, 9, 2, 22, 30, tzinfo=UTC)
monkeypatch.setattr(demo_seed.timezone, "now", lambda: near_local_midnight)
expected_local_day = timezone.localdate(near_local_midnight)
client.post(reverse("demo-login"))
first = JobPosting.objects.count()
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
profile = SearchProfile.objects.get(user=demo_user, is_active=True)
job = profile.scores.order_by("-score").first().job
job.status = JobPosting.Status.EXPIRED
job.last_seen = timezone.now() - timedelta(days=30)
job.save(update_fields=["status", "last_seen"])
application = Application.objects.filter(user=demo_user).first()
application.follow_up_date = timezone.localdate() - timedelta(days=14)
application.save(update_fields=["follow_up_date"])
client.logout()
client.post(reverse("demo-login"))
assert JobPosting.objects.count() == first
job.refresh_from_db()
application.refresh_from_db()
assert job.status == JobPosting.Status.ACTIVE
assert timezone.localdate(job.last_seen) == expected_local_day
assert application.follow_up_date > expected_local_day
assert profile.scores.count() == 6
assert MailboxConnection.objects.filter(user=demo_user, enabled=True).count() == 9
assert Source.objects.filter(source_type=Source.Type.EMAIL, failure_count=0).count() >= 9
def test_login_password_field_has_no_mask_placeholder(client):
html = client.get(reverse("login")).content.decode("utf-8")
assert 'id="id_password"' in html
assert 'placeholder="••••••••"' not in html
def test_demo_account_is_read_only_and_session_is_bounded(client):
from apps.profiles.models import SearchProfile
client.post(reverse("demo-login"))
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
active = SearchProfile.objects.get(user=demo_user, is_active=True)
alternative = SearchProfile.objects.create(user=demo_user, name="Alternatief", is_active=False)
response = client.post(
reverse("profiles:activate", args=[alternative.pk]),
HTTP_REFERER=reverse("profiles:list"),
)
assert response.status_code == 302
assert response.url == reverse("profiles:list")
active.refresh_from_db()
alternative.refresh_from_db()
assert active.is_active is True
assert alternative.is_active is False
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
def test_demo_account_can_still_log_out(client):
client.post(reverse("demo-login"))
response = client.post(reverse("logout"))
assert response.status_code == 302
assert client.session.get("_auth_user_id") is None
def test_authenticated_demo_page_disables_mutating_forms(client):
client.post(reverse("demo-login"))
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
assert 'data-demo-read-only="true"' in body
assert "Publieke demo · alleen-lezen" in body
assert "data-demo-allow" in body
def test_entra_login_disabled_redirects(client):
response = client.post(reverse("entra-login"))
assert response.status_code == 302
assert response.url == reverse("login")
@override_settings(ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_TENANT_ID="")
def test_entra_login_enabled_but_unconfigured_is_handled(client):
# Ingeschakeld maar zonder credentials: nette redirect, geen 500.
response = client.post(reverse("entra-login"), follow=False)
assert response.status_code == 302
assert response.url == reverse("login")
@override_settings(ENTRA_ID_ENABLED=True)
def test_entra_button_visible_when_enabled(client):
assert reverse("entra-login") in client.get(reverse("login")).content.decode("utf-8")
@@ -0,0 +1,147 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.jobs.models import Application, Feedback, JobPosting, JobSourceAlias
from apps.jobs.services.dedupe import candidate_similarity, find_existing_job, text_similarity
from apps.jobs.services.feedback import record_feedback
from apps.jobs.services.lifecycle import update_lifecycle
from apps.jobs.services.normalization import CanonicalJobDraft
from apps.sources.models import RawDocument
def _draft(job, **overrides):
values = {
"source_url": job.canonical_url,
"canonical_url": job.canonical_url,
"external_id": "external-1",
"title": job.original_title,
"normalized_title": job.normalized_title,
"job_family": job.job_family,
"employer_name": job.employer_name,
"employer_domain": job.employer.domain,
"location_text": job.raw_location,
"region": job.region,
"municipality": job.municipality,
"postal_code": "3500",
"country": "BE",
"workplace_type": job.workplace_type,
"employment_types": job.employment_types,
"language": "nl",
"description_html": "",
"description_text": job.description_text,
"date_posted": None,
"valid_through": None,
"compensation": {},
"skills_required": job.skills_required,
"skills_preferred": job.skills_preferred,
"content_hash": job.content_hash,
"canonical_key": job.canonical_key,
}
values.update(overrides)
return CanonicalJobDraft(**values)
@pytest.mark.django_db
def test_dedupe_exact_fuzzy_and_new(job, source):
document = RawDocument.objects.create(
source=source,
kind=RawDocument.Kind.HTML,
content_hash="e" * 64,
)
JobSourceAlias.objects.create(
job=job,
source=source,
raw_document=document,
url=job.canonical_url,
canonical_url=job.canonical_url,
external_id="external-1",
)
exact = find_existing_job(_draft(job))
assert exact.job == job
assert exact.reason == "exact_external_id"
fuzzy_draft = _draft(
job,
external_id="",
canonical_url="https://jobs.example.org/other-id",
canonical_key="f" * 64,
)
fuzzy = find_existing_job(fuzzy_draft, threshold=0.8)
assert fuzzy.job == job
assert fuzzy.reason == "fuzzy_strong"
assert candidate_similarity(job, fuzzy_draft) >= 0.8
assert text_similarity("", "x") == 0
new = find_existing_job(
_draft(
job,
external_id="",
canonical_url="https://new.example.org/job",
canonical_key="1" * 64,
title="Chef kok",
normalized_title="chef kok",
employer_name="Restaurant",
employer_domain="restaurant.example",
description_text="Koken in een restaurant",
)
)
assert new.job is None
assert new.reason == "new"
@pytest.mark.django_db
def test_applied_feedback_creates_application_and_follow_up(user, profile, job):
feedback = record_feedback(user=user, job=job, action=Feedback.Action.APPLIED, reason="goed")
assert feedback.profile == profile
application = Application.objects.get(user=user, job=job)
assert application.status == Application.Status.APPLIED
assert application.snapshot["title"] == job.original_title
assert application.follow_up_date == timezone.localdate() + timedelta(days=7)
application.status = Application.Status.PREPARING
application.save(update_fields=["status"])
record_feedback(user=user, job=job, action=Feedback.Action.APPLIED)
application.refresh_from_db()
assert application.status == Application.Status.APPLIED
@pytest.mark.django_db
def test_lifecycle_transitions(employer):
now = timezone.now()
expired = JobPosting.objects.create(
employer=employer,
original_title="Expired",
normalized_title="expired",
canonical_key="2" * 64,
content_hash="3" * 64,
valid_through=now - timedelta(minutes=1),
status=JobPosting.Status.ACTIVE,
)
uncertain = JobPosting.objects.create(
employer=employer,
original_title="Uncertain",
normalized_title="uncertain",
canonical_key="4" * 64,
content_hash="5" * 64,
last_seen=now - timedelta(days=5),
status=JobPosting.Status.ACTIVE,
)
removed = JobPosting.objects.create(
employer=employer,
original_title="Removed",
normalized_title="removed",
canonical_key="6" * 64,
content_hash="7" * 64,
last_seen=now - timedelta(days=20),
status=JobPosting.Status.UNCERTAIN,
)
result = update_lifecycle()
assert result == {"expired": 1, "uncertain": 1, "removed": 1}
expired.refresh_from_db()
uncertain.refresh_from_db()
removed.refresh_from_db()
assert expired.status == JobPosting.Status.EXPIRED
assert uncertain.status == JobPosting.Status.UNCERTAIN
assert removed.status == JobPosting.Status.REMOVED
@@ -0,0 +1,86 @@
from io import StringIO
import pytest
from django.core.management import call_command
from apps.sources.models import Source
@pytest.mark.django_db
def test_discover_sources_command_runs_for_all_modes_and_is_replayable():
out = StringIO()
call_command(
"discover_sources",
"html",
"--input",
"fixtures/discovery/html_candidate_discovery.html",
"--base-url",
"https://example.org/",
stdout=out,
)
call_command(
"discover_sources",
"sitemap",
"--input",
"fixtures/discovery/sitemap_index.xml",
"--base-url",
"https://jobs.example.org/",
stdout=out,
)
call_command(
"discover_sources",
"feed",
"--input",
"fixtures/discovery/feed_jobs.xml",
"--base-url",
"https://jobs.example.org/feed/jobs",
stdout=out,
)
call_command(
"discover_sources",
"email",
"--input",
"fixtures/emails/sample_alert.eml",
stdout=out,
)
source_types = {
(source.domain, source.source_type)
for source in Source.objects.filter(status=Source.Status.CANDIDATE)
}
assert ("example.org", Source.Type.EMPLOYER) in source_types
assert ("jobs.example.org", Source.Type.EMPLOYER) in source_types
assert ("example.org", Source.Type.RSS) in source_types
assert ("jobs.example.org", Source.Type.SITEMAP) in source_types
assert ("jobs.example.org", Source.Type.RSS) in source_types
assert "Persist result" in out.getvalue()
out.seek(0)
out.truncate(0)
call_command(
"discover_sources",
"html",
"--input",
"fixtures/discovery/html_candidate_discovery.html",
"--base-url",
"https://example.org/",
stdout=out,
)
assert Source.objects.filter(status=Source.Status.CANDIDATE).count() == 5
@pytest.mark.django_db
def test_discover_sources_dry_run_keeps_db_empty():
out = StringIO()
call_command(
"discover_sources",
"feed",
"--input",
"fixtures/discovery/feed_jobs.xml",
"--base-url",
"https://jobs.example.org/feed/jobs",
"--dry-run",
stdout=out,
)
assert Source.objects.count() == 0
assert "Dry-run modus" in out.getvalue()
@@ -0,0 +1,201 @@
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
from cryptography.fernet import Fernet
from django.core import mail
from django.test import override_settings
from apps.jobs.models import Feedback, ScoreRun
from apps.jobs.services.scoring import score_and_save
from apps.notifications.models import DigestOutbox
from apps.notifications.services import build_digest_payload, create_daily_outbox, send_digest
from apps.sources.models import EmailMessageRecord, MailboxConnection, RawDocument
from apps.sources.services.email_import import ingest_email, message_identity
from apps.sources.services.imap_import import poll_imap_mailbox
from apps.sources.services.mailbox_connections import encrypt_mailbox_password
@pytest.mark.django_db
def test_email_import_is_idempotent(profile):
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
identity = message_identity(raw)
first = ingest_email(raw)
second = ingest_email(raw)
assert first.pk == second.pk
assert first.message_id == identity
assert EmailMessageRecord.objects.count() == 1
assert RawDocument.objects.filter(kind=RawDocument.Kind.EMAIL).count() == 1
assert len(first.links) == 2
@pytest.mark.django_db
def test_vdab_alert_has_provider_provenance_and_replays_idempotently(profile):
raw = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
first = ingest_email(raw)
second = ingest_email(raw)
assert second.pk == first.pk
assert first.processed is True
assert first.raw_document.metadata["alert_provider"] == "vdab"
assert len(first.links) == 2
assert all("vdab.be/vindeenjob/vacatures/" in link for link in first.links)
@pytest.mark.django_db
@pytest.mark.parametrize(
("platform", "fixture", "expected_domain"),
(
(MailboxConnection.Platform.LINKEDIN, "linkedin_job_alert.eml", "linkedin.com"),
(MailboxConnection.Platform.ICTJOB, "ictjob_job_alert.eml", "ictjob.be"),
(MailboxConnection.Platform.JOBAT, "jobat_job_alert.eml", "jobat.be"),
(MailboxConnection.Platform.STEPSTONE, "stepstone_job_alert.eml", "stepstone.be"),
(MailboxConnection.Platform.CAREERJET, "careerjet_job_alert.eml", "careerjet.be"),
(MailboxConnection.Platform.RANDSTAD, "randstad_job_alert.eml", "randstad.be"),
(
MailboxConnection.Platform.ROBERTHALF,
"roberthalf_job_alert.eml",
"roberthalf.com",
),
),
)
def test_jobboard_mail_import_keeps_platform_provenance_and_rejects_external_links(
profile, platform, fixture, expected_domain
):
raw = (Path("fixtures/emails") / fixture).read_bytes()
record = ingest_email(raw, platform=platform)
assert record.processed is True
assert len(record.links) == 1
assert expected_domain in record.links[0]
assert "outside.example.invalid" not in record.links[0]
assert record.raw_document.metadata["alert_provider"] == platform
assert record.raw_document.source.name == f"{platform.label} vacaturemailbox"
@pytest.mark.django_db
def test_imap_poll_is_bounded_and_one_bad_message_does_not_abort(profile):
valid = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
oversized = b"x" * (len(valid) + 1)
class FakeImapClient:
def __init__(self, host, port, timeout=None):
self.host = host
self.port = port
self.timeout = timeout
self.logged_out = False
def login(self, user, password):
return "OK", []
def select(self, mailbox, readonly=True):
assert readonly is True
return "OK", []
def uid(self, command, *args):
if command == "search":
return "OK", [b"1 2 3"]
uid = args[0]
if uid == b"1":
return "OK", [(b"RFC822", valid)]
if uid == b"2":
return "OK", [(b"RFC822", oversized)]
return "NO", []
def logout(self):
self.logged_out = True
clients = []
def factory(host, port, timeout=None):
client = FakeImapClient(host, port, timeout=timeout)
clients.append(client)
return client
key = Fernet.generate_key().decode("ascii")
with override_settings(
MAILBOX_CREDENTIAL_KEYS=[key],
IMAP_MAX_MESSAGES_PER_POLL=3,
IMAP_MAX_MESSAGE_BYTES=len(valid),
):
connection = MailboxConnection.objects.create(
user=profile.user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.CUSTOM,
custom_host="imap.example.invalid",
username="radar@example.invalid",
encrypted_password=encrypt_mailbox_password("test-only"),
)
first = poll_imap_mailbox(
connection, client_factory=factory, host_validator=lambda url: url
)
second = poll_imap_mailbox(
connection, client_factory=factory, host_validator=lambda url: url
)
assert first == {
"status": "ok",
"imported": 1,
"duplicates": 0,
"skipped_oversized": 1,
"failed": 1,
}
assert second == {
"status": "ok",
"imported": 0,
"duplicates": 1,
"skipped_oversized": 1,
"failed": 1,
}
assert all(client.logged_out for client in clients)
record = EmailMessageRecord.objects.get(mailbox_connection=connection)
assert record.raw_document.metadata["alert_provider"] == "vdab"
assert record.raw_document.metadata["mailbox_connection_id"] == connection.pk
@pytest.mark.django_db
def test_digest_payload_outbox_deduplication_and_send(profile, job):
score = score_and_save(job, profile)
score.recommendation = ScoreRun.Recommendation.STRONG
score.score = 92
score.save(update_fields=["recommendation", "score"])
local_tz = ZoneInfo(profile.timezone)
due = datetime(2026, 7, 20, 7, 35, tzinfo=local_tz)
payload = build_digest_payload(profile, local_date=due.date())
assert payload["strong_count"] == 1
assert payload["scores"][0]["title"] == job.original_title
with override_settings(DIGEST_RECIPIENT="digest@example.invalid"):
first = create_daily_outbox(profile, now=due)
second = create_daily_outbox(profile, now=due)
assert first is not None
assert second.pk == first.pk
assert first.status == DigestOutbox.Status.PENDING
send_digest(first)
first.refresh_from_db()
assert first.status == DigestOutbox.Status.SENT
assert first.sent_at is not None
assert len(mail.outbox) == 1
assert "Infrastructure Engineer" in mail.outbox[0].body
@pytest.mark.django_db
def test_digest_skips_outside_window_and_hidden_jobs(profile, job):
score_and_save(job, profile)
Feedback.objects.create(
user=profile.user,
profile=profile,
job=job,
action=Feedback.Action.HIDE,
)
payload = build_digest_payload(profile)
assert payload["scores"] == []
local_tz = ZoneInfo(profile.timezone)
too_early = datetime(2026, 7, 20, 6, 0, tzinfo=local_tz)
assert create_daily_outbox(profile, now=too_early) is None
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from io import StringIO
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from apps.jobs.models import ScoreRun
from apps.jobs.services.scoring import score_and_save
@pytest.mark.django_db
def test_import_geodata_validate_only_and_import_command(tmp_path):
csv_path = tmp_path / "geodata.csv"
csv_path.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"1040,Brussel,Brussels Hoofdstedelijk Gewest,50.85,4.35\n"
)
out = StringIO()
call_command(
"import_geodata",
str(csv_path),
"--source-name",
"local-test",
"--dataset-version",
"2026-01-01",
"--validate-only",
"--license-name",
"Test Dataset",
"--license-url",
"https://example.org/license",
stdout=out,
)
assert "Validatie geslaagd (2 rijen)." in out.getvalue()
out = StringIO()
call_command(
"import_geodata",
str(csv_path),
"--source-name",
"local-test",
"--dataset-version",
"2026-01-01",
"--license-name",
"Test Dataset",
"--license-url",
"https://example.org/license",
stdout=out,
)
assert "Geocodebron geimporteerd (4 lookuprijen)" in out.getvalue()
@pytest.mark.django_db
def test_import_geodata_rejects_half_license_metadata(tmp_path):
csv_path = tmp_path / "geodata.csv"
csv_path.write_text(
"postal_code,municipality,region,latitude,longitude\n9000,Gent,Vlaams-Brabant,51.05,3.73\n"
)
out = StringIO()
with pytest.raises(CommandError):
call_command(
"import_geodata",
str(csv_path),
"--source-name",
"local-test",
"--dataset-version",
"2026-01-01",
"--license-name",
"Test Dataset",
stdout=out,
)
@pytest.mark.django_db
def test_import_geodata_triggers_targeted_rescore(tmp_path, profile, job):
job.postal_code = "9000"
job.municipality = "Gent"
job.save(update_fields=["postal_code", "municipality"])
score_and_save(job, profile)
initial_scores = ScoreRun.objects.filter(profile=profile, job=job).count()
csv_path = tmp_path / "geodata.csv"
csv_path.write_text(
"postal_code,municipality,region,latitude,longitude\n9000,Gent,Vlaams-Brabant,51.05,3.73\n"
)
out = StringIO()
call_command(
"import_geodata",
str(csv_path),
"--source-name",
"local-test",
"--dataset-version",
"2026-01-01",
"--license-name",
"Test Dataset",
"--license-url",
"https://example.org/license",
stdout=out,
)
assert "scoreregels herberekend voor aangepaste geodata" in out.getvalue()
assert ScoreRun.objects.filter(profile=profile, job=job).count() == initial_scores + 1
latest = ScoreRun.objects.filter(profile=profile, job=job).order_by("-created_at").first()
assert latest is not None
assert latest.evidence["distance_km"] is not None
@@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from apps.jobs.models import Feedback
@pytest.mark.integration
@pytest.mark.django_db
@pytest.mark.parametrize(
"route_name",
["jobs:activity", "jobs:employers", "dashboard:system"],
)
def test_intelligence_routes_require_login(client, route_name):
response = client.get(reverse(route_name))
assert response.status_code == 302
assert reverse("login") in response.url
@pytest.mark.integration
@pytest.mark.django_db
def test_activity_feed_filters_and_scopes_user_events(client, user, job):
other = get_user_model().objects.create_user(username="activity-other")
Feedback.objects.create(
user=other,
job=job,
action=Feedback.Action.HIDE,
reason="private-other-marker",
)
Feedback.objects.create(
user=user,
job=job,
action=Feedback.Action.INTERESTING,
reason="own-event-marker",
)
client.force_login(user)
response = client.get(reverse("jobs:activity"), {"type": "feedback"})
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "own-event-marker" in body
assert "private-other-marker" not in body
assert response.context["event_page"].paginator.count == 1
@pytest.mark.integration
@pytest.mark.django_db
def test_employer_index_and_detail_use_real_aggregates(client, user, profile, job, source):
client.force_login(user)
response = client.get(reverse("jobs:employers"), {"q": job.employer.name})
assert response.status_code == 200
assert response.context["selected_employer"].pk == job.employer_id
assert response.context["selected_employer"].active_vacancies == 1
assert response.context["selected_employer"].relevant_vacancies == 1
assert job.original_title in response.content.decode("utf-8")
detail = client.get(reverse("jobs:employer-detail", kwargs={"pk": job.employer_id}))
assert detail.status_code == 200
assert detail.context["employer"].pk == job.employer_id
assert [item.pk for item in detail.context["recent_jobs"]] == [job.pk]
@pytest.mark.integration
@pytest.mark.django_db
def test_automation_center_labels_unmeasured_runtime_honestly(client, user):
client.force_login(user)
response = client.get(reverse("dashboard:system"))
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "Automation center" in body
assert "worker niet gemeten" in body or "Eager/lokaal" in body
assert "Niet geconfigureerd" in body
assert len(response.context["phase_rows"]) == 8
@@ -0,0 +1,206 @@
from datetime import timedelta
import pytest
from cryptography.fernet import Fernet
from django.contrib.auth import get_user_model
from django.test import Client, override_settings
from django.urls import reverse
from django.utils import timezone
from apps.sources.models import MailboxConnection
from apps.sources.services.mailbox_connections import (
claim_mailbox_connection,
decrypt_mailbox_password,
)
from apps.sources.tasks import poll_mailbox, schedule_mailbox_polls
def _payload(**overrides):
payload = {
"platform": MailboxConnection.Platform.VDAB,
"provider": MailboxConnection.Provider.GMAIL,
"custom_host": "",
"port": 993,
"username": "vdab-alerts@example.invalid",
"password": "test-app-password",
"mailbox": "INBOX",
"poll_interval_minutes": 15,
"enabled": "on",
}
payload.update(overrides)
return payload
@pytest.mark.integration
@pytest.mark.django_db
def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, user, monkeypatch):
key = Fernet.generate_key().decode("ascii")
calls = []
monkeypatch.setattr(
"apps.sources.views.poll_mailbox.delay",
lambda connection_id, force=False: calls.append((connection_id, force)),
)
client.force_login(user)
with override_settings(MAILBOX_CREDENTIAL_KEYS=[key]):
response = client.post(reverse("sources:mailbox_add"), _payload())
assert response.status_code == 302
vdab = MailboxConnection.objects.get(user=user, platform="vdab")
assert decrypt_mailbox_password(vdab) == "test-app-password"
assert "test-app-password" not in vdab.encrypted_password
response = client.post(
reverse("sources:mailbox_add"),
_payload(
platform=MailboxConnection.Platform.INDEED,
provider=MailboxConnection.Provider.OUTLOOK,
username="indeed-alerts@example.invalid",
password="second-app-password",
poll_interval_minutes=30,
),
)
assert response.status_code == 302
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
assert indeed.encrypted_password == ""
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
assert edit_response.status_code == 200
assert b"test-app-password" not in edit_response.content
assert vdab.encrypted_password.encode() not in edit_response.content
original_ciphertext = vdab.encrypted_password
response = client.post(
reverse("sources:mailbox_edit", args=[vdab.pk]),
_payload(
username="vdab-updated@example.invalid",
password="",
poll_interval_minutes=5,
),
)
assert response.status_code == 302
vdab.refresh_from_db()
assert vdab.username == "vdab-updated@example.invalid"
assert vdab.poll_interval_minutes == 5
assert vdab.encrypted_password == original_ciphertext
assert decrypt_mailbox_password(vdab) == "test-app-password"
response = client.post(reverse("sources:mailbox_toggle", args=[indeed.pk]))
assert response.status_code == 302
indeed.refresh_from_db()
assert indeed.enabled is False
response = client.post(reverse("sources:sync_platform_alerts", args=[vdab.pk]))
assert response.status_code == 302
assert calls == [
(vdab.pk, True),
(indeed.pk, True),
(vdab.pk, True),
(vdab.pk, True),
]
@pytest.mark.integration
@pytest.mark.django_db
def test_sources_page_offers_all_reviewed_jobboard_mailboxes(client, user):
client.force_login(user)
response = client.get(reverse("sources:list"))
assert response.status_code == 200
for platform in MailboxConnection.Platform:
assert f'value="{platform.value}"'.encode() in response.content
assert f"{platform.label}-alert instellen".encode() in response.content
@pytest.mark.integration
@pytest.mark.django_db
def test_mailbox_views_are_user_scoped_and_require_encryption_key(client, user, monkeypatch):
other = get_user_model().objects.create_user(username="other", password="test-only-password")
other_connection = MailboxConnection.objects.create(
user=other,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
username="private@example.invalid",
encrypted_password="opaque-ciphertext",
)
client.force_login(user)
monkeypatch.setattr("apps.sources.views.poll_mailbox.delay", lambda *args, **kwargs: None)
assert (
client.get(reverse("sources:mailbox_edit", args=[other_connection.pk])).status_code == 404
)
assert (
client.post(reverse("sources:sync_platform_alerts", args=[other_connection.pk])).status_code
== 404
)
assert (
client.post(reverse("sources:mailbox_toggle", args=[other_connection.pk])).status_code
== 404
)
with override_settings(MAILBOX_CREDENTIAL_KEYS=[]):
response = client.post(reverse("sources:mailbox_add"), _payload(password="visible-once"))
assert response.status_code == 200
assert MailboxConnection.objects.filter(user=user).count() == 0
assert b"visible-once" not in response.content
assert b"Credentialopslag is niet geconfigureerd" in response.content
anonymous_response = Client().post(reverse("sources:mailbox_add"), _payload())
assert anonymous_response.status_code == 302
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(user)
assert csrf_client.post(reverse("sources:mailbox_add"), _payload()).status_code == 403
@pytest.mark.integration
@pytest.mark.django_db
def test_scheduler_queues_only_due_active_mailboxes_and_claim_is_exclusive(user, monkeypatch):
now = timezone.now()
due = MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
username="due@example.invalid",
encrypted_password="opaque",
next_poll_at=now - timedelta(minutes=1),
)
MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.INDEED,
provider=MailboxConnection.Provider.OUTLOOK,
username="later@example.invalid",
encrypted_password="opaque",
next_poll_at=now + timedelta(minutes=20),
)
calls = []
monkeypatch.setattr(
"apps.sources.tasks.poll_mailbox.delay", lambda connection_id: calls.append(connection_id)
)
assert schedule_mailbox_polls() == {"scheduled": 1}
assert calls == [due.pk]
assert claim_mailbox_connection(connection_id=due.pk, worker_token="worker-a") is not None
assert claim_mailbox_connection(connection_id=due.pk, worker_token="worker-b") is None
@pytest.mark.integration
@pytest.mark.django_db
def test_mailbox_task_does_not_persist_or_return_secret_on_imap_error(user, monkeypatch):
connection = MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
username="errors@example.invalid",
encrypted_password="opaque",
next_poll_at=timezone.now() - timedelta(minutes=1),
)
monkeypatch.setattr(
"apps.sources.tasks.poll_imap_mailbox",
lambda connection: (_ for _ in ()).throw(RuntimeError("leaked-app-password")),
)
result = poll_mailbox(connection.pk)
connection.refresh_from_db()
assert "leaked-app-password" not in str(result)
assert "leaked-app-password" not in connection.last_error_message
assert result == {"status": "failed", "error_category": "imap", "imported": 0}
+212
View File
@@ -0,0 +1,212 @@
import hashlib
from datetime import timedelta
from pathlib import Path
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from apps.jobs.models import (
Employer,
FieldProvenance,
JobPosting,
JobSourceAlias,
JobVersion,
ScoreRun,
)
from apps.jobs.services.pipeline import PersistenceContext, process_raw_document
from apps.sources.models import RawDocument, Source
@pytest.mark.integration
@pytest.mark.django_db
def test_pipeline_is_idempotent_and_scores(source, profile):
content = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
document = RawDocument.objects.create(
source=source,
url="https://jobs.example.org/vacatures/infrastructure-engineer",
final_url="https://jobs.example.org/vacatures/infrastructure-engineer",
kind=RawDocument.Kind.HTML,
content_type="text/html",
content_hash=hashlib.sha256(content.encode()).hexdigest(),
body_text=content,
byte_length=len(content.encode()),
retain_until=timezone.now() + timedelta(days=7),
)
first = process_raw_document(document)
second = process_raw_document(document)
assert first["created"] == 1
assert second["created"] == 0
assert JobPosting.objects.count() == 1
assert JobSourceAlias.objects.count() == 1
assert ScoreRun.objects.filter(profile=profile).count() == 2
@pytest.mark.integration
@pytest.mark.django_db
def test_unchanged_import_batch_has_bounded_lookup_queries(source, profile):
content = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
documents = [
RawDocument.objects.create(
source=source,
url=f"https://jobs.example.org/import/{index}",
final_url=f"https://jobs.example.org/import/{index}",
kind=RawDocument.Kind.HTML,
content_type="text/html",
content_hash=hashlib.sha256(f"{index}{content}".encode()).hexdigest(),
body_text=content,
byte_length=len(content.encode()),
retain_until=timezone.now() + timedelta(days=7),
)
for index in range(20)
]
context = PersistenceContext()
with CaptureQueriesContext(connection) as queries:
results = [process_raw_document(document, context=context) for document in documents]
assert sum(int(result["created"]) for result in results) == 1
assert sum(int(result["duplicates"]) for result in results) == 19
assert len(queries) < 300
assert Employer.objects.count() == 1
assert JobPosting.objects.count() == 1
assert JobVersion.objects.count() == 1
assert FieldProvenance.objects.count() > 0
assert ScoreRun.objects.filter(profile=profile).count() == 20
@pytest.mark.django_db
def test_pipeline_uses_reviewed_employer_identity_for_public_ats_feed(profile):
source = Source.objects.create(
name="Deliverect",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/deliverect?mode=json",
domain="api.lever.co",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
metadata={"employer_name": "Deliverect", "direct_employer": True},
)
content = Path("fixtures/ats/lever-public-postings.json").read_text(encoding="utf-8")
document = RawDocument.objects.create(
source=source,
url=source.base_url,
final_url=source.base_url,
kind=RawDocument.Kind.JSON,
content_type="application/json",
content_hash=hashlib.sha256(content.encode()).hexdigest(),
body_text=content,
byte_length=len(content.encode()),
retain_until=timezone.now() + timedelta(days=7),
)
result = process_raw_document(document)
assert result["created"] == 1
job = JobPosting.objects.get()
assert job.employer_name == "Deliverect"
assert job.direct_employer is True
evidence = job.source_aliases.get().field_evidence.get(field_name="employer_name")
assert evidence.extraction_method == "reviewed-source-config"
@pytest.mark.django_db
def test_pipeline_resolves_recruiter_alias_to_direct_employer():
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
employer = Employer.objects.create(
name="Example Public IT",
normalized_name="example public it",
domain="jobs.example.org",
is_direct_employer=True,
is_recruiter=False,
confidence=0.9,
)
direct_job = JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="8" * 64,
content_hash="e" * 64,
description_text="Beveiliger, hybride werk met support op locatie.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
content = """
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Security Engineer | Example Public IT</title>
<link rel="canonical" href="https://jobs.smartrecruiters.com/example/security-engineer">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "JobPosting",
"identifier": {
"@type": "PropertyValue",
"name": "Example Public IT",
"value": "VR-DEMO-SEC-001"
},
"title": "Security Engineer",
"description": "<p>Beveiliging, monitoring en hybride support.</p>",
"datePosted": "2026-07-20",
"validThrough": "2026-08-31T23:59:00+02:00",
"employmentType": ["FULL_TIME"],
"hiringOrganization": {
"@type": "Organization",
"name": "Example Public IT",
"sameAs": "https://www.example.org"
},
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"streetAddress": "Voorbeeldstraat 1",
"addressLocality": "Antwerpen",
"addressRegion": "Antwerpen",
"postalCode": "2000",
"addressCountry": "BE"
}
},
"jobLocationType": "HYBRID",
"url": "/example/security-engineer"
}
</script>
</head>
<body><main><h1>Security Engineer</h1></main></body>
</html>
""".strip()
body_hash = hashlib.sha256(content.encode()).hexdigest()
document = RawDocument.objects.create(
source=source,
url="https://jobs.smartrecruiters.com/example/security-engineer",
final_url="https://jobs.smartrecruiters.com/example/security-engineer",
kind=RawDocument.Kind.HTML,
content_type="text/html",
content_hash=body_hash,
body_text=content,
byte_length=len(content.encode()),
retain_until=timezone.now() + timedelta(days=7),
)
result = process_raw_document(document)
direct_job.refresh_from_db()
assert direct_job.canonical_url == "https://jobs.example.org/vacatures/security-engineer"
assert result["created"] == 0
assert result["duplicates"] == 1
alias = JobSourceAlias.objects.get(job=direct_job, source=source)
payload = alias.payload.get("employer_resolution")
assert payload is not None
assert payload["reason"] == "resolved_direct_match"
assert payload["resolved_direct"] is True
assert payload["canonical_url"] == "https://jobs.example.org/vacatures/security-engineer"
@@ -0,0 +1,119 @@
from decimal import Decimal
import pytest
from django.urls import reverse
from apps.jobs.models import GeocodeLocationLookup
from apps.profiles.forms import SearchProfileForm
from apps.profiles.taxonomy import iter_choices
def _profile_post_data(**overrides):
data = {
"name": "Testprofiel",
"is_active": "on",
"home_postal_code": "3500",
"max_distance_km": "45",
"experience_years": "10",
"desired_titles": ["infrastructure engineer", "cloud engineer"],
"excluded_titles": ["sales"],
"desired_skills": ["azure", "terraform"],
"excluded_skills": ["cold calling"],
"allowed_employment_types": ["full_time", "permanent"],
"preferred_workplace": ["hybrid"],
"preferred_regions": ["Limburg"],
"excluded_regions": ["West-Vlaanderen"],
"recommendation_threshold": "65",
"top_match_threshold": "90",
"digest_time": "07:30",
}
data.update(overrides)
return data
@pytest.mark.django_db
def test_profile_form_uses_guided_choices_and_preserves_existing_custom_values(profile):
form = SearchProfileForm(instance=profile)
assert form.fields["home_postal_code"].widget.attrs["maxlength"] == 4
assert "home_municipality" not in form.fields
assert "home_latitude" not in form.fields
assert "home_longitude" not in form.fields
assert form.fields["desired_titles"].widget.allow_multiple_selected is True
title_choices = list(iter_choices(form.fields["desired_titles"].choices))
skill_choices = list(iter_choices(form.fields["desired_skills"].choices))
assert ("it field engineer", "IT Field Engineer") in title_choices
assert ("system network engineer", "System & Network Engineer") in title_choices
assert ("systeembeheerder", "Systeembeheerder") in title_choices
assert ("vmware", "VMware") in skill_choices
assert ("microsoft copilot", "Microsoft Copilot") in skill_choices
assert ("fortinet", "Fortinet / FortiGate") in skill_choices
assert ("itil", "ITIL / IT-servicemanagement") in skill_choices
assert form.fields["desired_titles"].widget.attrs["class"] == "grouped-choices"
assert form.fields["experience_years"].widget.allow_multiple_selected is False
assert "nooit mee" in form.fields["experience_years"].help_text
@pytest.mark.django_db
def test_profile_form_rejects_non_belgian_postcode_and_conflicting_choices(profile):
invalid_postcode = SearchProfileForm(
data=_profile_post_data(home_postal_code="3500 Hasselt"), instance=profile
)
assert invalid_postcode.is_valid() is False
assert "home_postal_code" in invalid_postcode.errors
conflicting = SearchProfileForm(
data=_profile_post_data(preferred_regions=["Limburg"], excluded_regions=["Limburg"]),
instance=profile,
)
assert conflicting.is_valid() is False
assert "excluded_regions" in conflicting.errors
@pytest.mark.django_db
def test_profile_update_derives_home_location_from_postcode(client, user, profile):
GeocodeLocationLookup.objects.create(
source_name="test-geodata",
source_version="2026-01",
query_kind=GeocodeLocationLookup.QueryKind.POSTAL,
query_value="3500",
postal_code="3500",
municipality="Hasselt",
region="Limburg",
latitude=Decimal("50.930700"),
longitude=Decimal("5.332500"),
confidence=Decimal("1.00"),
)
client.force_login(user)
response = client.post(
reverse("profiles:edit", kwargs={"pk": profile.pk}), _profile_post_data()
)
assert response.status_code == 302
profile.refresh_from_db()
assert profile.home_postal_code == "3500"
assert profile.home_municipality == "Hasselt"
assert profile.home_latitude == Decimal("50.930700")
assert profile.experience_years == 10
assert profile.desired_titles == ["infrastructure engineer", "cloud engineer"]
assert profile.preferred_workplace == ["hybrid"]
@pytest.mark.django_db
def test_profile_update_clears_stale_coordinates_when_postcode_is_unknown(client, user, profile):
client.force_login(user)
response = client.post(
reverse("profiles:edit", kwargs={"pk": profile.pk}),
_profile_post_data(home_postal_code="9999"),
follow=True,
)
assert response.status_code == 200
assert "De postcode is bewaard" in response.content.decode("utf-8")
profile.refresh_from_db()
assert profile.home_postal_code == "9999"
assert profile.home_municipality == ""
assert profile.home_latitude is None
assert profile.home_longitude is None
+132
View File
@@ -0,0 +1,132 @@
from datetime import datetime, time
from decimal import Decimal
from zoneinfo import ZoneInfo
import pytest
from django.utils import timezone
from apps.jobs.models import Application, Feedback, ScoreRun
from apps.jobs.services.scoring import score_and_save
from apps.notifications.models import ReminderOutbox
from apps.notifications.services import (
create_closing_reminders,
create_follow_up_reminders,
create_top_match_reminders,
)
@pytest.mark.django_db
def test_closing_reminder_is_idempotent_and_skips_hidden(profile, job):
zone = ZoneInfo(profile.timezone)
now = timezone.now().astimezone(zone)
job.valid_through = datetime(
year=now.year,
month=now.month,
day=now.day,
hour=20,
tzinfo=zone,
)
job.save(update_fields=["valid_through"])
Feedback.objects.create(
user=profile.user,
profile=profile,
job=job,
action=Feedback.Action.HIDE,
)
assert create_closing_reminders(profile, now=now) == []
Feedback.objects.filter(user=profile.user, profile=profile, job=job).delete()
first = create_closing_reminders(profile, now=now)[0]
second = create_closing_reminders(profile, now=now)[0]
assert first.pk == second.pk
assert first.status == ReminderOutbox.Status.PENDING
@pytest.mark.django_db
def test_follow_up_reminder_respects_configured_weekdays(profile, job):
zone = ZoneInfo(profile.timezone)
now = timezone.now().astimezone(zone)
follow_date = now.date()
Application.objects.create(
user=profile.user,
job=job,
follow_up_date=follow_date,
status=Application.Status.APPLIED,
notes="",
)
profile.follow_up_weekdays = [(follow_date.weekday() + 1) % 7]
profile.save(update_fields=["follow_up_weekdays"])
assert create_follow_up_reminders(profile, now=now) == []
profile.follow_up_weekdays = [follow_date.weekday()]
profile.save(update_fields=["follow_up_weekdays"])
reminders = create_follow_up_reminders(profile, now=now)
assert len(reminders) == 1
assert reminders[0].reminder_type == ReminderOutbox.ReminderType.FOLLOW_UP
@pytest.mark.django_db
def test_top_match_reminder_requires_threshold_and_profile_setting(profile, job):
profile.top_match_reminders_enabled = True
profile.top_match_threshold = 90
profile.save(update_fields=["top_match_reminders_enabled", "top_match_threshold"])
score = score_and_save(job, profile)
score.recommendation = ScoreRun.Recommendation.STRONG
score.score = 85
score.confidence = Decimal("0.9")
score.save(update_fields=["recommendation", "score", "confidence"])
assert create_top_match_reminders(profile) == []
score.score = 95
score.confidence = Decimal("0.90")
score.save(update_fields=["score", "confidence"])
score.confidence = Decimal("0.84")
score.save(update_fields=["confidence"])
assert create_top_match_reminders(profile) == []
score.confidence = Decimal("0.86")
score.save(update_fields=["confidence"])
reminders = create_top_match_reminders(profile)
assert len(reminders) == 1
assert reminders[0].reminder_type == ReminderOutbox.ReminderType.TOP_MATCH
profile.top_match_reminders_enabled = False
profile.save(update_fields=["top_match_reminders_enabled"])
assert create_top_match_reminders(profile) == []
@pytest.mark.django_db
def test_reminder_obeys_quiet_hours_and_empty_recipient(profile, job):
zone = ZoneInfo(profile.timezone)
now = timezone.now().astimezone(zone).replace(hour=6, minute=30, second=0, microsecond=0)
job.valid_through = datetime(
year=now.year,
month=now.month,
day=now.day,
hour=18,
tzinfo=zone,
)
job.save(update_fields=["valid_through"])
profile.user.email = ""
profile.user.save(update_fields=["email"])
assert create_closing_reminders(profile, now=now)[0].status == ReminderOutbox.Status.SKIPPED
profile.user.email = "reminder@example.invalid"
profile.user.save(update_fields=["email"])
reminder = create_closing_reminders(profile, now=now)[0]
assert reminder.status == ReminderOutbox.Status.PENDING
assert reminder.scheduled_for.timetz().replace(tzinfo=None) == time(7, 0)
@pytest.mark.django_db
def test_reminder_service_handles_dst_dates(profile, job):
zone = ZoneInfo(profile.timezone)
now = datetime(2026, 10, 25, 1, 30, tzinfo=zone)
job.valid_through = now.replace(hour=20)
job.save(update_fields=["valid_through"])
reminder = create_closing_reminders(profile, now=now)[0]
assert reminder.scheduled_for.tzinfo is not None
assert reminder.scheduled_for.tzinfo.key == "Europe/Brussels"
+91
View File
@@ -0,0 +1,91 @@
from datetime import timedelta
import pytest
import yaml
from django.core.management import call_command
from django.utils import timezone
from apps.sources.models import Source, SourcePolicyReview
from apps.sources.views import _source_list_queryset
@pytest.mark.django_db
def test_seed_sources_registers_reviewed_live_feed_and_hides_demo(tmp_path):
Source.objects.create(
name="Voorbeeldwerkgever (fixture)",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
)
expires_at = (timezone.now() + timedelta(days=30)).isoformat()
retired_source = Source.objects.create(
name="Brusselse werkgever",
source_type=Source.Type.ATS,
base_url="https://brussels-employer.example/jobs",
domain="brussels-employer.example",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
next_run_at=timezone.now(),
)
payload = {
"disable_demo_sources": True,
"retire_source_urls": [retired_source.base_url],
"sources": [
{
"name": "VITO",
"url": "https://vito.recruitee.com/api/offers/",
"type": "ats",
"status": "trial",
"policy": "review",
"parser": "ats-recruitee",
"allow_public_endpoint": True,
"metadata": {"country_scope": "BE"},
"review": {
"decision": "trial",
"reason": "Publieke feed",
"evidence_link": "https://docs.recruitee.com/reference/offers",
"expires_at": expires_at,
},
},
{
"name": "Lever werkgever A",
"url": "https://api.lever.co/v0/postings/werkgever-a?mode=json",
"type": "ats",
"status": "trial",
"policy": "allow",
"parser": "ats-lever",
"allow_public_endpoint": True,
},
{
"name": "Lever werkgever B",
"url": "https://api.lever.co/v0/postings/werkgever-b?mode=json",
"type": "ats",
"status": "trial",
"policy": "allow",
"parser": "ats-lever",
"allow_public_endpoint": True,
},
],
}
seed_path = tmp_path / "sources.yaml"
seed_path.write_text(yaml.safe_dump(payload), encoding="utf-8")
call_command("seed_sources", str(seed_path))
call_command("seed_sources", str(seed_path))
demo = Source.objects.get(domain="jobs.example.org")
assert demo.status == Source.Status.DISABLED
assert demo.metadata["demo"] is True
retired_source.refresh_from_db()
assert retired_source.status == Source.Status.DISABLED
assert retired_source.next_run_at is None
assert retired_source.metadata["hidden_from_source_list"] is True
assert retired_source.metadata["retired_reason"] == "Buiten de ingestelde regionale dekking"
source = Source.objects.get(name="VITO")
assert retired_source not in _source_list_queryset()
assert source in _source_list_queryset()
assert source.allow_public_endpoint is True
assert source.parser_key == "ats-recruitee"
assert source.latest_policy_review.decision == SourcePolicyReview.Decision.TRIAL
assert source.policy_reviews.count() == 1
assert Source.objects.filter(domain="api.lever.co", source_type=Source.Type.ATS).count() == 2
+88
View File
@@ -0,0 +1,88 @@
import pytest
from django.urls import reverse
from apps.jobs.models import JobPosting, ScoreRun
@pytest.mark.integration
@pytest.mark.django_db
def test_skill_insights_require_login(client):
response = client.get(reverse("jobs:skill-insights"))
assert response.status_code == 302
assert reverse("login") in response.url
@pytest.mark.integration
@pytest.mark.django_db
def test_skill_insights_use_current_profile_and_exclude_hidden_jobs(client, user, profile, job):
job.skills_required = ["Microsoft 365"]
job.description_text = "Microsoft 365, Dynamics 365 en Microsoft Copilot governance."
job.save(update_fields=["skills_required", "description_text"])
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=74,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
hidden = JobPosting.objects.create(
employer=job.employer,
original_title="Network Engineer buiten bereik",
normalized_title="network engineer buiten bereik",
canonical_url="https://jobs.example.org/vacatures/network-hidden",
canonical_key="1" * 64,
content_hash="2" * 64,
description_text="Beheer FortiGate-firewalls.",
status=JobPosting.Status.ACTIVE,
)
ScoreRun.objects.create(
job=hidden,
profile=profile,
profile_version=profile.version,
score=80,
confidence=0.9,
recommendation=ScoreRun.Recommendation.HIDDEN,
hard_exclusions=["Buiten de zoekradius."],
)
client.force_login(user)
response = client.get(reverse("jobs:skill-insights"))
body = response.content.decode()
assert response.status_code == 200
assert "Skillsradar" in body
assert "Microsoft 365" in body
assert "Microsoft Dynamics 365" in body
assert "Microsoft Copilot" in body
assert "Fortinet / FortiGate" not in body
assert "Ervaringsjaren tellen nergens mee" in body
@pytest.mark.integration
@pytest.mark.django_db
def test_skill_insights_offer_category_and_gap_filters(client, user, profile, job):
job.skills_required = ["Microsoft 365", "FortiGate"]
job.description_text = "Microsoft 365 en FortiGate-beheer."
job.save(update_fields=["skills_required", "description_text"])
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=74,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
client.force_login(user)
response = client.get(
reverse("jobs:skill-insights"),
{"category": "netwerk-security", "coverage": "gap"},
)
body = response.content.decode()
assert response.status_code == 200
assert "Fortinet / FortiGate" in body
assert "Microsoft 365" not in body
assert "Leerkans" in body
+188
View File
@@ -0,0 +1,188 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.sources.models import RawDocument, Source, SourceRun
from apps.sources.services.health import collect_source_health
from apps.sources.tasks import cleanup_raw_documents, enforce_source_health, recover_source_health
def _create_run(
source: Source,
*,
status: str,
started_at=None,
error_category: str = "",
extracted_count: int = 0,
created_count: int = 0,
updated_count: int = 0,
duplicate_count: int = 0,
http_status: int = 200,
metrics: dict[str, object] | None = None,
):
started_at = started_at or timezone.now()
finished_at = started_at + timedelta(seconds=1)
return SourceRun.objects.create(
source=source,
started_at=started_at,
finished_at=finished_at,
status=status,
error_category=error_category,
http_status=http_status,
extracted_count=extracted_count,
created_count=created_count,
updated_count=updated_count,
duplicate_count=duplicate_count,
metrics=metrics or {},
)
@pytest.mark.django_db
def test_enforce_source_health_quarantines_on_temporary_failures(source):
for index in range(6):
_create_run(
source,
status=SourceRun.Status.FAILED,
started_at=timezone.now() - timedelta(minutes=index),
error_category="timeout",
)
result = enforce_source_health()
assert result["quarantined"] == 1
source.refresh_from_db()
assert source.status == Source.Status.QUARANTINED
assert source.policy == Source.Policy.DENY
@pytest.mark.django_db
def test_enforce_source_health_quarantines_on_policy_category(source):
for index in range(3):
_create_run(
source,
status=SourceRun.Status.FAILED,
started_at=timezone.now() - timedelta(minutes=index),
error_category="policy",
)
result = enforce_source_health()
assert result["quarantined"] == 1
source.refresh_from_db()
assert source.status == Source.Status.QUARANTINED
assert "beleid" in source.policy_reason
@pytest.mark.django_db
def test_enforce_source_health_quarantines_on_parser_drift(source):
for index in range(3):
_create_run(
source,
status=SourceRun.Status.SUCCESS,
started_at=timezone.now() - timedelta(minutes=index),
extracted_count=0,
created_count=0,
updated_count=0,
duplicate_count=0,
http_status=200,
metrics={"parser": "generic-html", "warnings": ["veld ontbreekt", "datum ontbreekt"]},
)
assert enforce_source_health()["quarantined"] == 1
source.refresh_from_db()
assert source.status == Source.Status.QUARANTINED
assert source.policy == Source.Policy.DENY
@pytest.mark.django_db
def test_collect_source_health_exposes_parser_and_counts(source):
_create_run(
source,
status=SourceRun.Status.SUCCESS,
error_category="",
extracted_count=2,
created_count=1,
updated_count=1,
duplicate_count=0,
metrics={"parser": "jsonld", "warnings": ["warn"]},
)
row = collect_source_health()[0]
assert row.source_id == source.pk
assert row.last_parser == "jsonld"
assert row.last_parser_warnings == 1
assert row.extracted_count == 2
assert row.updated_count == 1
@pytest.mark.django_db
def test_collect_source_health_prefetches_runs_for_all_sources(source, django_assert_num_queries):
second_source = Source.objects.create(
name="Second jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://second.example.org/jobs/",
domain="second.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
_create_run(source, status=SourceRun.Status.SUCCESS)
_create_run(second_source, status=SourceRun.Status.SUCCESS)
with django_assert_num_queries(2):
rows = collect_source_health()
assert [row.source_name for row in rows] == ["Example jobs", "Second jobs"]
@pytest.mark.django_db
def test_recover_source_health_starts_canary_once(source, monkeypatch):
source.status = Source.Status.QUARANTINED
source.policy = Source.Policy.DENY
source.metadata = {
"source_health": {
"state": "quarantined",
"quarantine_reason": "tijdelijke fout",
"quarantined_at": timezone.now().isoformat(),
"recovery_due_at": timezone.now().isoformat(),
"canary_started": False,
}
}
source.save(update_fields=["status", "policy", "metadata"])
calls: list[int] = []
def fake_delay(source_id: int, force: bool = False):
calls.append(source_id)
return source_id
monkeypatch.setattr("apps.sources.tasks.fetch_source.delay", fake_delay)
result = recover_source_health()
assert result["canary_started"] == 1
assert calls == [source.pk]
source.refresh_from_db()
assert source.status == Source.Status.TRIAL
assert source.policy == Source.Policy.REVIEW
# Canary is eenmalig, niet continu herstartbaar.
source.refresh_from_db()
assert recover_source_health()["canary_started"] == 0
@pytest.mark.django_db
def test_cleanup_retains_quarantined_raw_documents(source):
old = RawDocument.objects.create(
source=source,
kind=RawDocument.Kind.TEXT,
content_hash="a" * 64,
retain_until=timezone.now() - timedelta(days=1),
)
keep = RawDocument.objects.create(
source=source,
kind=RawDocument.Kind.TEXT,
content_hash="b" * 64,
retain_until=timezone.now() - timedelta(days=1),
quarantined=True,
)
result = cleanup_raw_documents()
assert result["deleted"] == 1
assert RawDocument.objects.filter(pk=old.pk).exists() is False
assert RawDocument.objects.filter(pk=keep.pk).exists() is True
@@ -0,0 +1,110 @@
from __future__ import annotations
import pytest
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
from apps.sources.services.fetcher import FetchedDocument
from apps.sources.services.manual_import import ManualImportError
def test_manual_import_view_prefills_source_url(client, user):
client.force_login(user)
response = client.get(
reverse("sources:manual_import"), {"source_url": "https://jobs.example.org/vacature"}
)
assert response.status_code == 200
assert 'value="https://jobs.example.org/vacature"' in response.content.decode("utf-8")
@pytest.mark.django_db
def test_manual_import_view_posts_url_and_shows_result(client, user, monkeypatch):
def fake_fetch(*_args, **_kwargs):
return FetchedDocument(
requested_url="https://jobs.example.org/engineering",
final_url="https://jobs.example.org/engineering",
status_code=200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"<html><body>vacature</body></html>",
)
def fake_process(_document):
return {
"extracted": 1,
"created": 1,
"updated": 0,
"duplicates": 0,
"parser": "manual-test",
"warnings": [],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fake_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
client.force_login(user)
response = client.post(
reverse("sources:manual_import"),
{"source_url": "https://jobs.example.org/engineering"},
)
html = response.content.decode("utf-8")
assert response.status_code == 200
assert "Importresultaat" in html
assert "Herkenbare items: 1" in html
@pytest.mark.django_db
def test_manual_import_view_paste_prefers_post_flow_and_does_not_fetch(client, user, monkeypatch):
def fail_fetch(*_args, **_kwargs):
raise AssertionError("Fetcher should not run for paste imports.")
def fake_process(_document):
return {
"extracted": 1,
"created": 0,
"updated": 0,
"duplicates": 1,
"parser": "manual-test",
"warnings": [],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fail_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
client.force_login(user)
response = client.post(
reverse("sources:manual_import"),
{"pasted_text": "Eerste regel\nTweede regel"},
)
html = response.content.decode("utf-8")
assert response.status_code == 200
assert "Importresultaat" in html
assert "Modus: paste" in html
@pytest.mark.django_db
@override_settings(
MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS=1,
MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS=120,
MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS=60,
)
def test_manual_import_blocks_after_failed_attempts(client, user, monkeypatch):
cache.clear()
def fail_import(*_args, **_kwargs):
raise ManualImportError("Handmatige import faalde in test")
client.force_login(user)
monkeypatch.setattr("apps.sources.views.import_manual_source", fail_import)
response = client.post(
reverse("sources:manual_import"),
{"source_url": "https://jobs.example.org/engineering"},
)
assert "Handmatige import faalde in test" in response.content.decode("utf-8")
response = client.post(
reverse("sources:manual_import"),
{"source_url": "https://jobs.example.org/engineering"},
)
assert "Te veel handmatige importverzoeken" in response.content.decode("utf-8")
@@ -0,0 +1,336 @@
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import Application, Feedback, ScoreRun
from apps.jobs.tasks import rescore_active_jobs, update_job_lifecycle
from apps.notifications.tasks import build_due_digests
from apps.sources.models import RawDocument, Source, SourceLease, SourcePolicyReview, SourceRun
from apps.sources.services.fetcher import FetchedDocument, RateLimitedError
from apps.sources.tasks import cleanup_raw_documents, fetch_source, schedule_due_sources
@pytest.mark.django_db
def test_task_helpers(profile, job, source, monkeypatch):
result = rescore_active_jobs(profile.pk)
assert result["scores_created"] == 1
assert ScoreRun.objects.filter(job=job, profile=profile).exists()
assert update_job_lifecycle() == {"expired": 0, "uncertain": 0, "removed": 0}
calls = []
monkeypatch.setattr(
"apps.sources.tasks.fetch_source.delay", lambda source_id: calls.append(source_id)
)
source.next_run_at = timezone.now() - timedelta(minutes=1)
source.save(update_fields=["next_run_at"])
assert schedule_due_sources()["scheduled"] == 1
assert calls == [source.pk]
old = RawDocument.objects.create(
source=source,
kind=RawDocument.Kind.TEXT,
content_hash="8" * 64,
retain_until=timezone.now() - timedelta(days=1),
)
assert cleanup_raw_documents()["deleted"] >= 1
assert not RawDocument.objects.filter(pk=old.pk).exists()
source.policy = Source.Policy.DENY
source.policy_reason = "test"
source.save(update_fields=["policy", "policy_reason"])
assert fetch_source(source.pk)["status"] == "skipped"
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
def test_fetch_source_skips_when_lease_blocked(db, source, monkeypatch):
SourceLease.objects.create(
source=source,
token="active-worker",
worker_id="active-worker",
expires_at=timezone.now() + timedelta(minutes=2),
)
calls = {"count": 0}
def fake_fetch_url(*args, **kwargs):
calls["count"] += 1
return FetchedDocument(
requested_url=source.base_url,
final_url=source.base_url,
status_code=200,
headers={},
content=b"Vacature",
)
monkeypatch.setattr("apps.sources.tasks.fetch_url", fake_fetch_url)
assert fetch_source(source.pk)["status"] == "skipped"
assert calls["count"] == 0
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
def test_fetch_source_handles_rate_limit_backoff(db, source, monkeypatch):
called = {"count": 0}
def fake_fetch_url(*args, **kwargs):
called["count"] += 1
raise RateLimitedError(30)
monkeypatch.setattr("apps.sources.tasks.fetch_url", fake_fetch_url)
result = fetch_source(source.pk)
assert result["status"] == "rate_limited"
assert called["count"] == 1
source.refresh_from_db()
assert source.next_run_at is not None
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.FAILED).exists()
def test_fetch_source_resets_fail_state_on_304(db, source, monkeypatch):
source.failure_count = 4
source.save(update_fields=["failure_count"])
def fake_fetch_url(*args, **kwargs):
return FetchedDocument(
requested_url=source.base_url,
final_url=source.base_url,
status_code=304,
headers={},
content=b"",
)
monkeypatch.setattr("apps.sources.tasks.fetch_url", fake_fetch_url)
assert fetch_source(source.pk)["status"] == "not_modified"
source.refresh_from_db()
assert source.failure_count == 0
def test_due_source_scheduler_never_schedules_mailbox_sources(db, monkeypatch):
from apps.sources.tasks import schedule_due_sources
email_source = Source.objects.create(
name="Mailboxbron",
source_type=Source.Type.EMAIL,
base_url="",
domain="example.mailbox.local",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
scheduled = []
monkeypatch.setattr(
"apps.sources.tasks.fetch_source.delay", lambda source_id: scheduled.append(source_id)
)
result = schedule_due_sources()
assert email_source.pk not in scheduled
assert result["scheduled"] == len(scheduled)
def test_mailbox_scheduler_excludes_public_demo_user(db, monkeypatch, settings):
from django.contrib.auth import get_user_model
from apps.sources.models import MailboxConnection
from apps.sources.tasks import schedule_mailbox_polls
demo = get_user_model().objects.create_user(username=settings.DEMO_USERNAME)
connection = MailboxConnection.objects.create(
user=demo,
platform="vdab",
provider="outlook",
username="demo@example.test",
mailbox="Demo/VDAB",
enabled=True,
next_poll_at=timezone.now() - timedelta(minutes=1),
)
scheduled = []
monkeypatch.setattr(
"apps.sources.tasks.poll_mailbox.delay",
lambda connection_id: scheduled.append(connection_id),
)
schedule_mailbox_polls()
assert connection.pk not in scheduled
def test_fetch_source_is_blocked_by_conflicting_review(db):
source = Source.objects.create(
name="Blocked source",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
SourcePolicyReview.objects.create(
source=source,
decision=SourcePolicyReview.Decision.DENY,
reason="Review blokkeert deze bron",
)
result = fetch_source(source.pk)
assert result["status"] == "skipped"
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
def test_fetch_source_is_blocked_by_expired_review(db):
source = Source.objects.create(
name="Expired review source",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
SourcePolicyReview.objects.create(
source=source,
decision=SourcePolicyReview.Decision.ALLOW,
reason="Review verlopen",
expires_at=timezone.now() - timedelta(days=1),
)
result = fetch_source(source.pk)
assert result["status"] == "skipped"
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
# Outside the digest window this is a safe no-op.
assert build_due_digests()["sent"] == 0
@pytest.mark.integration
@pytest.mark.django_db
def test_job_profile_source_and_application_views(client, user, profile, job, source):
client.force_login(user)
assert client.get(reverse("jobs:list"), {"q": "Infrastructure"}).status_code == 200
assert client.get(reverse("jobs:detail", kwargs={"pk": job.pk})).status_code == 200
response = client.post(
reverse("jobs:feedback", kwargs={"pk": job.pk}),
{"action": Feedback.Action.INTERESTING},
)
assert response.status_code == 302
assert Feedback.objects.filter(user=user, job=job).exists()
unsafe_redirect = client.post(
reverse("jobs:feedback", kwargs={"pk": job.pk}),
{
"action": Feedback.Action.INTERESTING,
"next": "https://malicious.example/steal-session",
},
)
assert unsafe_redirect.url == reverse("jobs:detail", kwargs={"pk": job.pk})
assert client.get(reverse("profiles:list")).status_code == 200
assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200
assert client.get(reverse("sources:list")).status_code == 200
assert client.get(reverse("dashboard:system")).status_code == 200
applications_response = client.get(reverse("jobs:applications"))
assert applications_response.status_code == 200
assert [column["status"] for column in applications_response.context["pipeline_columns"]] == [
status for status, _label in Application.Status.choices
]
source.policy = Source.Policy.DENY
source.save(update_fields=["policy"])
response = client.post(reverse("sources:retry", kwargs={"pk": source.pk}))
assert response.status_code == 302
@pytest.mark.integration
@pytest.mark.django_db
def test_sources_candidate_bulk_actions_and_evidence_view(client, user, profile):
profile.home_postal_code = "2400"
profile.max_distance_km = 40
profile.home_latitude = None
profile.home_longitude = None
profile.save(
update_fields=[
"home_postal_code",
"max_distance_km",
"home_latitude",
"home_longitude",
"updated_at",
]
)
candidate = Source.objects.create(
name="Discoverd candidate",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.CANDIDATE,
policy=Source.Policy.REVIEW,
metadata={
"discovery": [
{
"url": "https://jobs.example.org/careers",
"discovered_from": "html",
"reason": "career-link",
"confidence": 0.9,
"label": "Werken bij",
}
]
},
)
other = Source.objects.create(
name="Another source",
source_type=Source.Type.EMPLOYER,
base_url="https://other.example.org/",
domain="other.example.org",
status=Source.Status.CANDIDATE,
policy=Source.Policy.REVIEW,
)
watchlist = Source.objects.create(
name="Interessante werkgever zonder stabiele feed",
source_type=Source.Type.EMPLOYER,
base_url="https://watch.example.org/jobs/",
domain="watch.example.org",
status=Source.Status.PAUSED,
policy=Source.Policy.REVIEW,
metadata={
"watchlist": True,
"municipality_scope": "Olen",
"watchlist_reason": "Regionaal interessante werkgever",
"current_observation": "Momenteel geen lokale vacature bevestigd.",
"public_job_page": "https://watch.example.org/jobs/",
},
)
client.force_login(user)
response = client.get(reverse("sources:list"))
assert response.status_code == 200
content = response.content.decode("utf-8")
assert "jobs.example.org/careers" in content
assert "Platformmailboxen" in content
assert "Mailbox koppelen en testen" in content
assert "elke vijf minuten" in content
assert "2400 Mol" in content
assert "maximaal 40 km" in content
assert "Coördinaten ontbreken nog" in content
assert "Interessante werkgeverswaaklijst" in content
assert watchlist.name in content
assert response.context["watchlist_count"] == 1
assert {item.pk for item in response.context["sources"]} == {candidate.pk, other.pk}
response = client.post(
reverse("sources:bulk"),
{"action": "approve_to_trial", "source_ids": [str(candidate.pk)]},
)
assert response.status_code == 302
candidate.refresh_from_db()
assert candidate.status == Source.Status.TRIAL
response = client.post(
reverse("sources:bulk"),
{"action": "dismiss", "source_ids": [str(other.pk)]},
)
assert response.status_code == 302
other.refresh_from_db()
assert other.status == Source.Status.DISABLED
assert other.policy == Source.Policy.DENY
response = client.post(
reverse("sources:bulk"),
{"action": "dismiss", "source_ids": [str(watchlist.pk)]},
)
assert response.status_code == 302
watchlist.refresh_from_db()
assert watchlist.status == Source.Status.PAUSED
assert watchlist.policy == Source.Policy.REVIEW
+242
View File
@@ -0,0 +1,242 @@
import pytest
from django.conf import settings
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import JobPosting, ScoreRun
@pytest.mark.integration
@pytest.mark.django_db
def test_today_requires_login(client):
response = client.get(reverse("dashboard:today"))
assert response.status_code == 302
assert reverse("login") in response.url
@pytest.mark.integration
@pytest.mark.django_db
def test_browser_favicon_and_product_brand_assets_are_local(client):
response = client.get("/favicon.ico")
assert response.status_code == 301
assert response.url.endswith("/static/favicon.svg")
favicon = (settings.BASE_DIR / "static" / "favicon.svg").read_text(encoding="utf-8")
product_logo = (settings.BASE_DIR / "static" / "img" / "vacatureradar-logo.svg").read_text(
encoding="utf-8"
)
wordmark = settings.BASE_DIR / "static" / "img" / "itworx-wordmark.png"
for signature in ('stroke="#19d3e6"', 'stroke="#7df5ff"', 'fill="#45d69b"'):
assert signature in favicon
assert 'stroke="#19d3e6"' in product_logo
assert 'fill="#36c990"' in product_logo
assert "<rect" not in product_logo
assert 'viewBox="0 0 64 64"' in favicon
assert wordmark.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
assert wordmark.stat().st_size < 100_000
@pytest.mark.integration
@pytest.mark.django_db
def test_today_loads_for_user(client, user, profile):
client.force_login(user)
response = client.get(reverse("dashboard:today"))
assert response.status_code == 200
assert "Vandaag" in response.content.decode()
body = response.content.decode()
assert 'aria-label="VacatureRadar dashboard"' in body
assert "Intelligence cockpit" in body
assert "Ontwikkeld door" in body
assert 'aria-label="Ontwikkeld door ITWorx.tech"' in body
assert 'class="sidebar-footer"' in body
assert 'class="sidebar-account"' not in body
assert "Dagoverzicht" in body
assert 'content="VacatureRadar"' in body
def test_premium_layout_uses_one_bounded_ultrawide_grid():
css = (settings.BASE_DIR / "static" / "css" / "premium.css").read_text(encoding="utf-8")
assert "--content-frame: 1720px" in css
assert "width: min(100%, var(--content-frame))" in css
assert "margin-inline: auto" in css
assert ".topbar-inner" in css
assert "--content-frame: 2200px" in css
assert "--content-frame: 2480px" in css
assert "@media (min-width: 1800px)" in css
assert "minmax(650px, 880px) minmax(760px, 1fr)" in css
@pytest.mark.integration
@pytest.mark.django_db
def test_demo_banner_is_part_of_main_content_flow(client, user, settings):
settings.DEMO_MODE_ENABLED = True
settings.DEMO_READ_ONLY = True
settings.DEMO_USERNAME = user.username
client.force_login(user)
response = client.get(reverse("dashboard:today"))
body = response.content.decode()
assert response.status_code == 200
assert body.index('<main id="main"') < body.index('class="demo-mode-banner"')
assert 'role="status" aria-label="Publieke demo, alleen-lezen"' in body
def test_css_imports_share_the_release_cache_version():
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
assert css.count("?v=0.3.13") == 7
@pytest.mark.integration
@pytest.mark.django_db
def test_job_list_defaults_to_ranked_it_focus_but_keeps_explicit_archive(
client, user, profile, job
):
non_it = JobPosting.objects.create(
employer=job.employer,
original_title="Spontane sollicitatie - logopedist",
normalized_title="spontane sollicitatie logopedist",
canonical_url="https://jobs.example.org/vacatures/logopedist",
canonical_key="9" * 64,
content_hash="8" * 64,
description_text="Zorgfunctie met toegang tot digitale patiëntendossiers.",
status=JobPosting.Status.ACTIVE,
)
misleading_it_title = JobPosting.objects.create(
employer=job.employer,
original_title="IT Recruitment Specialist",
normalized_title="it recruitment specialist",
canonical_url="https://jobs.example.org/vacatures/it-recruitment",
canonical_key="3" * 64,
content_hash="2" * 64,
status=JobPosting.Status.ACTIVE,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=60,
confidence=0.9,
recommendation=ScoreRun.Recommendation.WEAK,
positives=["IT-focus bevestigd via de functietitel."],
)
ScoreRun.objects.create(
job=non_it,
profile=profile,
profile_version=profile.version,
score=99,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
hidden_it = JobPosting.objects.create(
employer=job.employer,
original_title="Cloud Engineer buiten de zoekradius",
normalized_title="cloud engineer buiten de zoekradius",
canonical_url="https://jobs.example.org/vacatures/cloud-ver",
canonical_key="5" * 64,
content_hash="4" * 64,
status=JobPosting.Status.ACTIVE,
)
ScoreRun.objects.create(
job=hidden_it,
profile=profile,
profile_version=profile.version,
score=98,
confidence=0.9,
recommendation=ScoreRun.Recommendation.HIDDEN,
hard_exclusions=["Buiten de zoekradius."],
)
client.force_login(user)
focused = client.get(reverse("jobs:list"))
focused_body = focused.content.decode()
assert job.original_title in focused_body
assert non_it.original_title not in focused_body
assert misleading_it_title.original_title not in focused_body
assert hidden_it.original_title not in focused_body
assert focused.context["it_job_count"] == 2
assert focused.context["filtered_non_it_count"] == 2
archive = client.get(reverse("jobs:list"), {"focus": "all", "match": "all"})
archive_body = archive.content.decode()
assert non_it.original_title in archive_body
assert misleading_it_title.original_title in archive_body
assert hidden_it.original_title in archive_body
@pytest.mark.integration
@pytest.mark.django_db
def test_today_uses_latest_user_score_and_excludes_non_it(client, user, profile, job):
non_it = JobPosting.objects.create(
employer=job.employer,
original_title="Technieker Facilitaire Diensten",
normalized_title="technieker facilitaire diensten",
canonical_url="https://jobs.example.org/vacatures/facilitair",
canonical_key="7" * 64,
content_hash="6" * 64,
status=JobPosting.Status.ACTIVE,
first_seen=timezone.now(),
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=70,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=66,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
ScoreRun.objects.create(
job=non_it,
profile=profile,
profile_version=profile.version,
score=95,
confidence=0.9,
recommendation=ScoreRun.Recommendation.STRONG,
)
client.force_login(user)
response = client.get(reverse("dashboard:today"))
cards = list(response.context["score_cards"])
assert [card.job_id for card in cards] == [job.id]
assert cards[0].score == 66
assert response.context["filtered_non_it_jobs"] == 1
@pytest.mark.integration
@pytest.mark.django_db
@override_settings(
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=2,
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=120,
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300,
)
def test_login_view_rate_limits_failed_attempts(client, user):
cache.clear()
response = client.post(
reverse("login"),
{"username": user.username, "password": "wrong"},
)
assert response.status_code == 200
response = client.post(
reverse("login"),
{"username": user.username, "password": "wrong"},
)
body = response.content.decode("utf-8")
assert "Te veel inlogpogingen" in body
response = client.post(
reverse("login"),
{"username": user.username, "password": "wrong"},
)
assert "Te veel inlogpogingen" in response.content.decode("utf-8")
+109
View File
@@ -0,0 +1,109 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.sources.models import Source, SourcePolicyReview
from apps.sources.services.policy import assess_url, create_policy_review, is_denied_domain
from apps.sources.services.robots import RobotsDecision
def test_platform_domains_are_denied_including_subdomains():
for domain in (
"www.linkedin.com",
"be.indeed.com",
"www.ictjob.be",
"www.jobat.be",
"www.stepstone.be",
"www.careerjet.be",
"jobs.randstad.be",
"www.roberthalf.com",
):
assert is_denied_domain(domain)
assert not is_denied_domain("jobs.example.org")
assert not assess_url("https://www.linkedin.com/jobs/view/123").allowed
assert not assess_url("https://www.ictjob.be/nl/it-vacatures-zoeken/example").allowed
@pytest.mark.security
def test_source_policy_without_review_is_blocked(db):
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = assess_url(source.base_url, source=source)
assert not decision.allowed
assert decision.status == Source.Policy.REVIEW
@pytest.mark.security
def test_source_review_expiry_blocks_fetch_decision(db):
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
create_policy_review(
source,
actor=None,
decision=SourcePolicyReview.Decision.ALLOW,
reason="Aanvankelijk goedgekeurd",
scope=SourcePolicyReview.Scope.SOURCE,
expires_at=timezone.now() - timedelta(days=1),
)
decision = assess_url(source.base_url, source=source)
assert not decision.allowed
@pytest.mark.security
def test_policy_conflict_denies_even_when_allow_policy_set(db):
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
create_policy_review(
source,
actor=None,
decision=SourcePolicyReview.Decision.DENY,
reason="Juridische review: geblokkeerd",
scope=SourcePolicyReview.Scope.SOURCE,
)
decision = assess_url(source.base_url, source=source)
assert not decision.allowed
assert decision.status == Source.Policy.DENY
@pytest.mark.security
def test_trial_review_can_allow_fetch(db, monkeypatch):
monkeypatch.setattr(
"apps.sources.services.policy.assess_robots",
lambda *args, **kwargs: RobotsDecision(True, "Testrobots staan toegang toe"),
)
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.TRIAL,
policy=Source.Policy.REVIEW,
)
create_policy_review(
source,
actor=None,
decision=SourcePolicyReview.Decision.TRIAL,
reason="Technische proefrun gestart",
scope=SourcePolicyReview.Scope.SOURCE,
)
decision = assess_url(source.base_url, source=source)
assert decision.allowed
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
import httpx
import pytest
from django.utils import timezone
from apps.sources.models import Source, SourceRobotsCache
from apps.sources.services.robots import assess_robots
from apps.sources.services.url_security import ValidatedUrl
def _allow_test_dns(monkeypatch):
monkeypatch.setattr(
"apps.sources.services.robots.validate_public_url",
lambda url, **kwargs: ValidatedUrl(
url=url,
hostname="jobs.example.org",
port=443,
addresses=("93.184.216.34",),
),
)
@pytest.mark.security
def test_robots_uses_user_agent_matching_and_allows_default(db, monkeypatch):
_allow_test_dns(monkeypatch)
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
calls = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["count"] += 1
assert request.url.path == "/robots.txt"
body = (
"User-agent: *\n"
"Disallow: /admin\n"
"User-agent: vacatureradar/0.1 (+local-personal-use)\n"
"Allow: /admin\n"
)
return httpx.Response(200, text=body)
client = httpx.Client(transport=httpx.MockTransport(handler))
decision = assess_robots(
source.base_url + "admin",
source=source,
user_agent="VacatureRadar/0.1 (+local-personal-use)",
client=client,
now=timezone.now(),
)
client.close()
assert decision.allowed
assert calls["count"] == 1
@pytest.mark.security
def test_robots_cache_refreshes_after_ttl(db, settings, monkeypatch):
_allow_test_dns(monkeypatch)
source = Source.objects.create(
name="Example jobs",
source_type=Source.Type.EMPLOYER,
base_url="https://jobs.example.org/vacatures/",
domain="jobs.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
settings.ROBOTS_CACHE_TTL_SECONDS = 0
calls = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["count"] += 1
return httpx.Response(200, text="User-agent: *\nAllow: /")
client = httpx.Client(transport=httpx.MockTransport(handler))
assess_robots(
source.base_url, source=source, user_agent="test-agent", client=client, now=timezone.now()
)
assert calls["count"] == 1
assess_robots(
source.base_url,
source=source,
user_agent="test-agent",
client=client,
now=timezone.now(),
)
assert calls["count"] == 2
assert SourceRobotsCache.objects.filter(origin__contains="jobs.example.org").exists()
client.close()
@pytest.mark.security
def test_robots_follows_and_revalidates_redirects(db, monkeypatch):
validated_urls = []
def validate(url, **kwargs):
validated_urls.append(url)
if "127.0.0.1" in url:
from apps.sources.services.url_security import UnsafeUrlError
raise UnsafeUrlError("Privéadres")
return ValidatedUrl(
url=url,
hostname="jobs.example.org",
port=443,
addresses=("93.184.216.34",),
)
monkeypatch.setattr("apps.sources.services.robots.validate_public_url", validate)
source = Source.objects.create(
name="Redirect jobs",
source_type=Source.Type.ATS,
base_url="https://ats.example.org/jobs",
domain="ats.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
def safe_handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "ats.example.org":
return httpx.Response(
302,
headers={"location": "https://jobs.example.org/robots.txt"},
request=request,
)
return httpx.Response(200, text="User-agent: *\nAllow: /", request=request)
client = httpx.Client(transport=httpx.MockTransport(safe_handler))
assert assess_robots(source.base_url, source=source, client=client).allowed is True
client.close()
assert validated_urls == [
"https://ats.example.org/robots.txt",
"https://jobs.example.org/robots.txt",
]
SourceRobotsCache.objects.all().delete()
def unsafe_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
302, headers={"location": "http://127.0.0.1/robots.txt"}, request=request
)
client = httpx.Client(transport=httpx.MockTransport(unsafe_handler))
blocked = assess_robots(source.base_url, source=source, client=client)
client.close()
assert blocked.allowed is False
assert "Privéadres" in blocked.reason
+18
View File
@@ -0,0 +1,18 @@
import pytest
from apps.jobs.services.sanitize import sanitize_job_html
@pytest.mark.security
def test_sanitize_removes_script_handlers_forms_and_iframes():
dirty = (
'<p onclick="alert(1)">Hallo</p><script>alert(1)</script>'
'<iframe src="x"></iframe><form><input></form>'
'<a href="javascript:alert(1)">x</a>'
)
clean = sanitize_job_html(dirty)
assert "script" not in clean.lower()
assert "onclick" not in clean.lower()
assert "iframe" not in clean.lower()
assert "form" not in clean.lower()
assert "javascript:" not in clean.lower()
+34
View File
@@ -0,0 +1,34 @@
import socket
import pytest
from apps.sources.services.url_security import UnsafeUrlError, validate_public_url
def fake_resolver(address: str):
def resolver(host, port, type=socket.SOCK_STREAM):
return [(socket.AF_INET, type, 6, "", (address, port))]
return resolver
@pytest.mark.security
def test_blocks_private_ipv4_after_dns_resolution():
with pytest.raises(UnsafeUrlError, match="Niet-publiek"):
validate_public_url("https://jobs.example.org/test", resolver=fake_resolver("10.0.0.5"))
@pytest.mark.security
def test_blocks_loopback_and_embedded_credentials():
with pytest.raises(UnsafeUrlError):
validate_public_url("http://127.0.0.1/admin")
with pytest.raises(UnsafeUrlError):
validate_public_url("https://user:pass@example.org/")
@pytest.mark.security
def test_accepts_global_address():
result = validate_public_url(
"https://jobs.example.org/test", resolver=fake_resolver("93.184.216.34")
)
assert result.hostname == "jobs.example.org"
+148
View File
@@ -0,0 +1,148 @@
import json
from pathlib import Path
import httpx
import pytest
from django.test import override_settings
from apps.jobs.models import AiAnalysisCache
from apps.jobs.services import ai as ai_service
from apps.jobs.services.ai import analyze_job_text
pytestmark = pytest.mark.django_db
def _evaluation_cases() -> list[dict[str, object]]:
path = Path(__file__).resolve().parents[2] / "fixtures" / "ai" / "evaluation_set.json"
return json.loads(path.read_text(encoding="utf-8-sig"))["cases"]
@pytest.mark.parametrize("case", _evaluation_cases(), ids=lambda case: case["id"])
def test_ai_evaluation_cases(monkeypatch, case):
if case["response_type"] == "disabled":
with override_settings(OLLAMA_ENABLED=False, OLLAMA_MODEL=""):
result = analyze_job_text(
title=case["title"],
description=case["description"],
content_hash=case["content_hash"],
)
assert result.status == ai_service.AiAnalysisCache.Status.DISABLED
assert result.error_category == "ollama_disabled"
assert result.cached is False
assert result.summary_nl == ""
return
if case["response_type"] == "timeout":
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
raise httpx.TimeoutException("timeout")
monkeypatch.setattr(httpx, "Client", FakeClient)
else:
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
return _fake_response(case["response"])
monkeypatch.setattr(httpx, "Client", FakeClient)
with override_settings(
OLLAMA_ENABLED=True,
OLLAMA_MODEL="local-model",
OLLAMA_BASE_URL="http://ollama:11434",
OLLAMA_TIMEOUT_SECONDS=2,
):
result = analyze_job_text(
title=case["title"],
description=case["description"],
content_hash=case["content_hash"],
)
assert result.status == case["expected_status"]
assert result.error_category == case["expected_error_category"]
assert result.schema_version == "1.0.0"
if result.status == ai_service.AiAnalysisCache.Status.OK:
assert result.summary_nl == case["expected_summary"]
assert round(result.features.get("support_ratio", 0), 3) == case["expected_support_ratio"]
assert result.features.get("evidence") == case["expected_evidence"]
if result.status != ai_service.AiAnalysisCache.Status.OK:
assert result.features == {}
def _fake_response(response_text: object):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"response": response_text}
return FakeResponse()
def test_ai_cache_reuses_result(monkeypatch):
calls = 0
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
nonlocal calls
calls += 1
return _fake_response(
'{"summary_nl":"Gevalideerde IT-ops advertentie.",'
'"features":{"support_ratio":0.03,"consultancy_ratio":0.01,'
'"travel_ratio":0.02,"seniority":"senior","evidence":["IT"]},'
'"warnings":[]}'
)
monkeypatch.setattr(httpx, "Client", FakeClient)
with override_settings(
OLLAMA_ENABLED=True,
OLLAMA_MODEL="local-model",
OLLAMA_BASE_URL="http://ollama:11434",
):
first = analyze_job_text(
"Cloud Engineer",
"Cloud Engineer vacature. IT-ops taken incl. Azure en Linux.",
content_hash="cache-hit-case",
)
second = analyze_job_text(
"Cloud Engineer",
"Cloud Engineer vacature. IT-ops taken incl. Azure en Linux.",
content_hash="cache-hit-case",
)
assert first.status == ai_service.AiAnalysisCache.Status.OK
assert second.status == ai_service.AiAnalysisCache.Status.OK
assert first.cached is False
assert second.cached is True
assert calls == 1
assert AiAnalysisCache.objects.count() == 1
+215
View File
@@ -0,0 +1,215 @@
from pathlib import Path
from apps.sources.adapters.ats import (
GreenhouseAdapter,
LeverAdapter,
RecruiteeAdapter,
SmartRecruitersAdapter,
WorkableAdapter,
)
def _load_fixture(name: str) -> str:
return Path(name).read_text(encoding="utf-8")
def _assert_evidence_fields(job, *, expected_keys):
evidence_fields = {item.field_name for item in job.evidence}
assert expected_keys.issubset(evidence_fields)
def test_greenhouse_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = GreenhouseAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/greenhouse-listing.json"),
url="https://boards.greenhouse.io/example",
)
assert result.parser_key == "ats-greenhouse"
assert len(result.jobs) == 2
assert {job.external_id for job in result.jobs} == {"gh-open-1", "gh-open-2"}
assert all("closed" not in result.warnings for _ in [0])
assert result.confidence >= 0.89
_assert_evidence_fields(
result.jobs[0],
expected_keys={"external_id", "url", "location_text", "date_posted", "employment_types"},
)
def test_greenhouse_adapter_detail_supports_changed_markup():
adapter = GreenhouseAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/greenhouse-detail.json"),
url="https://boards.greenhouse.io/example/jobs/senior-software-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Senior Software Engineer"
assert job.external_id == "gh-open-1"
assert job.url == "https://boards.greenhouse.io/example/jobs/senior-software-engineer"
assert job.description_html
_assert_evidence_fields(
job,
expected_keys={
"external_id",
"url",
"location_text",
"date_posted",
"valid_through",
"employment_types",
},
)
def test_lever_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = LeverAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/lever-listing.json"), url="https://jobs.lever.co/example"
)
assert result.parser_key == "ats-lever"
assert len(result.jobs) == 2
assert {job.external_id for job in result.jobs} == {"lv-open-1", "lv-open-2"}
assert result.confidence >= 0.89
def test_lever_adapter_detail_supports_changed_markup():
adapter = LeverAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/lever-detail.json"),
url="https://jobs.lever.co/example/platform-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Platform Engineer"
assert job.raw["position"]["workplaceType"] == "remote"
assert "work_type" in job.raw
assert job.location_text == "Aalst"
def test_lever_adapter_supports_documented_public_postings_api_shape():
adapter = LeverAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/lever-public-postings.json"),
url="https://api.lever.co/v0/postings/deliverect?mode=json",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Backend Engineer"
assert job.url == "https://jobs.lever.co/deliverect/deliverect-open-1"
assert job.location_text == "Ghent, Flemish Region"
assert job.employment_types == ["Full-time Permanent"]
assert job.workplace_type == "hybrid"
def test_recruitee_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = RecruiteeAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/recruitee-listing.json"), url="https://acme.recruitee.com/o/"
)
assert result.parser_key == "ats-recruitee"
assert len(result.jobs) == 2
assert {job.title for job in result.jobs} == {"System Engineer", "Network Engineer"}
def test_recruitee_adapter_detail_supports_changed_markup():
adapter = RecruiteeAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/recruitee-detail.json"),
url="https://acme.recruitee.com/o/system-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.external_id == "rc-open-1"
assert job.title == "System Engineer"
assert job.employment_types == ["permanent"]
def test_recruitee_adapter_supports_documented_public_offers_shape():
adapter = RecruiteeAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/recruitee-public-offers.json"),
url="https://vito.recruitee.com/api/offers/",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Research Software Engineer"
assert job.employer_name == "VITO"
assert job.url == "https://jobs.vito.be/o/research-software-engineer"
assert job.postal_code == "2400"
assert job.employment_types == ["fulltime_fixed_term"]
assert job.workplace_type == "hybrid"
def test_smartrecruiters_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = SmartRecruitersAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/smartrecruiters-listing.json"),
url="https://jobs.smartrecruiters.com/example",
)
assert result.parser_key == "ats-smartrecruiters"
assert len(result.jobs) == 2
assert "sr-closed-1" not in {job.external_id for job in result.jobs}
def test_smartrecruiters_adapter_detail_supports_changed_markup():
adapter = SmartRecruitersAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/smartrecruiters-detail.json"),
url="https://jobs.smartrecruiters.com/example/security-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Security Engineer"
assert job.workplace_type in {"remote", "hybrid", ""}
def test_smartrecruiters_adapter_supports_documented_public_postings_shape():
adapter = SmartRecruitersAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/smartrecruiters-public-postings.json"),
url=("https://api.smartrecruiters.com/v1/companies/ARHS/postings?limit=100&country=be"),
)
assert len(result.jobs) == 2
job = result.jobs[1]
assert job.title == "Cloud Engineer"
assert job.employer_name == "ARHS"
assert job.url == "https://jobs.smartrecruiters.com/ARHS/744000136173889"
assert job.location_text == "Leuven"
assert job.country == "be"
assert job.employment_types == ["Permanent"]
assert job.workplace_type == "remote"
def test_workable_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = WorkableAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/workable-listing.json"),
url="https://apply.workable.com/example",
)
assert result.parser_key == "ats-workable"
assert len(result.jobs) == 2
assert result.jobs[0].title == "Data Engineer"
def test_workable_adapter_detail_supports_changed_markup():
adapter = WorkableAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/workable-detail.json"),
url="https://apply.workable.com/example/data-engineer/",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.external_id == "wb-open-1"
assert job.location_text == "Brussel"
+87
View File
@@ -0,0 +1,87 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from apps.core.auth_backends import EmailOrUsernameModelBackend
from apps.core.entra import (
EntraConfigError,
domain_allowed,
entra_enabled,
extract_identity,
load_config,
)
def test_auth_backend_rejects_missing_and_unknown_credentials(db):
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username=None, password=None) is None
assert backend.authenticate(None, username="unknown@example.invalid", password="secret") is None
def test_auth_backend_rejects_inactive_and_wrong_password(db):
user = get_user_model().objects.create_user(
username="inactive", email="inactive@example.invalid", password="correct", is_active=False
)
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username=user.email, password="wrong") is None
assert backend.authenticate(None, username=user.email, password="correct") is None
def test_auth_backend_handles_duplicate_email_without_ambiguous_login(db):
user_model = get_user_model()
first = user_model.objects.create_user(
username="first", email="shared@example.invalid", password="correct"
)
user_model.objects.create_user(
username="second", email="shared@example.invalid", password="correct"
)
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username="shared@example.invalid", password="correct") is None
assert backend.authenticate(None, username="first", password="correct") == first
@override_settings(ENTRA_ID_ENABLED=True)
def test_entra_enabled_reflects_configuration():
assert entra_enabled() is True
@override_settings(
ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_CLIENT_SECRET="", ENTRA_TENANT_ID=""
)
def test_entra_config_fails_closed_when_credentials_are_incomplete():
try:
load_config()
except EntraConfigError as exc:
assert "niet allemaal ingesteld" in str(exc)
else: # pragma: no cover - defensive assertion
raise AssertionError("Onvolledige Entra-configuratie werd geaccepteerd")
@override_settings(
ENTRA_CLIENT_ID="client",
ENTRA_CLIENT_SECRET="secret",
ENTRA_TENANT_ID="tenant",
ENTRA_SCOPES=["User.Read"],
ENTRA_ALLOWED_DOMAINS=["@Example.COM"],
ENTRA_REDIRECT_URI="https://example.invalid/callback",
)
def test_entra_config_normalizes_domains_and_identity_claims():
config = load_config()
assert config.authority.endswith("/tenant")
assert config.allowed_domains == ("example.com",)
assert domain_allowed(config, "person@example.com") is True
assert domain_allowed(config, "person@other.invalid") is False
assert extract_identity(
{"id_token_claims": {"preferred_username": " person@example.com ", "name": " Person "}}
) == ("person@example.com", "Person")
@override_settings(
ENTRA_CLIENT_ID="client",
ENTRA_CLIENT_SECRET="secret",
ENTRA_TENANT_ID="tenant",
ENTRA_ALLOWED_DOMAINS=[],
)
def test_entra_allows_any_domain_only_when_allowlist_is_empty():
assert domain_allowed(load_config(), "person@example.invalid") is True
+13
View File
@@ -0,0 +1,13 @@
from apps.sources.services.canonicalize import canonicalize_url, domain_matches
def test_canonicalize_url_removes_tracking_and_fragment():
value = canonicalize_url(
"HTTPS://Jobs.Example.org:443/jobs/../jobs/42/?utm_source=mail&b=2&a=1#apply"
)
assert value == "https://jobs.example.org/jobs/42/?a=1&b=2"
def test_domain_matches_subdomains_but_not_suffix_attack():
assert domain_matches("www.linkedin.com", "linkedin.com")
assert not domain_matches("linkedin.com.evil.example", "linkedin.com")
+112
View File
@@ -0,0 +1,112 @@
from pathlib import Path
import pytest
from apps.sources.models import Source
from apps.sources.services.discovery import (
SourceCandidate,
discover_career_links,
discover_from_email,
discover_from_feed,
discover_from_html,
discover_from_sitemap,
persist_discovery_candidates,
)
def test_discovery_finds_career_links_and_skips_denied_domains():
html = """
<a href="/werken-bij">Werken bij ons</a>
<a href="https://careers.partner.example/jobs">Jobs partner</a>
<a href="https://www.linkedin.com/jobs">Jobs op LinkedIn</a>
<a href="/about">Over ons</a>
"""
result = discover_career_links(html, base_url="https://example.org/")
assert [item.url for item in result] == [
"https://example.org/werken-bij",
"https://careers.partner.example/jobs",
]
assert result[0].confidence > result[1].confidence
def test_discovery_from_html_includes_jsonld_and_feed_links():
html = Path("fixtures/discovery/html_candidate_discovery.html").read_text(encoding="utf-8")
result = discover_from_html(html, base_url="https://example.org/")
candidate_urls = {candidate.url: candidate.discovered_from for candidate in result}
assert candidate_urls["https://example.org/werken-bij"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer"] == "html-jsonld"
assert (
candidate_urls["https://jobs.example.org/vacatures/data-engineer/solliciteer"]
== "html-jsonld"
)
assert candidate_urls["https://example.org/feed/jobs.xml"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer"].startswith(
"html-jsonld"
)
assert any(c.source_type == Source.Type.RSS and c.reason == "feed" for c in result)
assert any(
c.source_type == Source.Type.EMPLOYER and c.url == "https://example.org/werken-bij"
for c in result
)
def test_discovery_from_feed_detects_internal_links_only():
content = Path("fixtures/discovery/feed_jobs.xml").read_text(encoding="utf-8")
result = discover_from_feed(content, base_url="https://jobs.example.org/feed/jobs")
assert len(result) == 1
assert result[0].url == "https://jobs.example.org/vacatures/cloud-architect"
assert result[0].source_type == Source.Type.RSS
def test_discovery_from_sitemap_prefers_candidates_and_filters_private_or_denied():
index_content = Path("fixtures/discovery/sitemap_index.xml").read_text(encoding="utf-8")
urlset_content = Path("fixtures/discovery/sitemap_urlset.xml").read_text(encoding="utf-8")
index_candidates = discover_from_sitemap(index_content, base_url="https://jobs.example.org/")
urlset_candidates = discover_from_sitemap(urlset_content, base_url="https://jobs.example.org/")
assert len(index_candidates) == 1
assert index_candidates[0].url == "https://jobs.example.org/sitemap-jobs.xml"
assert len(urlset_candidates) >= 2
assert any(
item.url == "https://jobs.example.org/vacatures/data-engineer" for item in urlset_candidates
)
assert all("linkedin.com" not in item.url for item in index_candidates + urlset_candidates)
def test_discovery_from_email_extracts_employer_domain_root():
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
result = discover_from_email(raw)
assert [item.url for item in result] == ["https://jobs.example.org/"]
assert result[0].source_type == Source.Type.EMPLOYER
@pytest.mark.django_db
def test_discovery_persistence_is_idempotent():
candidates = [
SourceCandidate(
url="https://jobs.example.org/vacatures/data-engineer",
domain="jobs.example.org",
source_type=Source.Type.EMPLOYER,
label="Testbron",
confidence=0.95,
reason="test",
discovered_from="unit-test",
)
]
created, updated, skipped = persist_discovery_candidates(candidates)
assert (created, updated, skipped) == (1, 0, 0)
created, updated, skipped = persist_discovery_candidates(candidates)
assert (created, updated, skipped) == (0, 0, 1)
source = Source.objects.get(domain="jobs.example.org", source_type=Source.Type.EMPLOYER)
assert source.status == Source.Status.CANDIDATE
assert source.policy == Source.Policy.REVIEW
assert source.name == "Testbron"
assert len(source.discovery_evidence) == 1
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
import pytest
from apps.sources.adapters.email_alert import EmailAlertAdapter
from apps.sources.models import MailboxConnection
from apps.sources.platforms import PLATFORM_ALERTS
def test_email_adapter_extracts_job_links_and_skips_unsubscribe():
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
result = EmailAlertAdapter().extract_message(raw)
urls = {job.url for job in result.jobs}
assert "https://jobs.example.org/vacatures/infrastructure-engineer" in urls
assert "https://www.linkedin.com/jobs/view/123456" in urls
assert all("unsubscribe" not in url for url in urls)
def test_email_adapter_recognizes_sanitized_vdab_alert():
raw = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
result = EmailAlertAdapter().extract_message(raw)
assert {job.title for job in result.jobs} == {"Python developer", "Cloud engineer"}
assert all(job.raw["alert_provider"] == "vdab" for job in result.jobs)
assert all(job.raw["target_domain"] == "www.vdab.be" for job in result.jobs)
assert all("uitschrijven" not in job.url for job in result.jobs)
@pytest.mark.parametrize(
("provider", "fixture", "title", "domain"),
(
("linkedin", "linkedin_job_alert.eml", "Software Engineer bij Example", "linkedin.com"),
("ictjob", "ictjob_job_alert.eml", "Cloud Engineer", "ictjob.be"),
("jobat", "jobat_job_alert.eml", "Data Engineer", "jobat.be"),
("stepstone", "stepstone_job_alert.eml", "Software Developer", "stepstone.be"),
("careerjet", "careerjet_job_alert.eml", "System Engineer", "careerjet.be"),
("randstad", "randstad_job_alert.eml", "Infrastructure Engineer", "randstad.be"),
("roberthalf", "roberthalf_job_alert.eml", "Network Engineer", "roberthalf.com"),
),
)
def test_platform_bound_alert_extracts_only_expected_https_jobboard_link(
provider, fixture, title, domain
):
raw = (Path("fixtures/emails") / fixture).read_bytes()
result = EmailAlertAdapter().extract_message(raw, expected_provider=provider)
assert [job.title for job in result.jobs] == [title]
assert result.jobs[0].raw["alert_provider"] == provider
assert result.jobs[0].raw["target_domain"].endswith(domain)
assert result.jobs[0].url.startswith("https://")
assert "outside.example.invalid" not in result.jobs[0].url
def test_mailbox_platform_choices_and_alert_catalog_stay_in_sync():
assert set(MailboxConnection.Platform.values) == set(PLATFORM_ALERTS)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from apps.jobs.models import Employer, JobPosting
from apps.jobs.services.employer_resolution import resolve_direct_employer_match
from apps.jobs.services.normalization import CanonicalJobDraft
from apps.sources.models import Source
def _draft_for_test(job: JobPosting, **overrides) -> CanonicalJobDraft:
values = {
"source_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"canonical_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"external_id": "sr-security-123",
"title": job.original_title,
"normalized_title": job.normalized_title,
"job_family": job.job_family,
"employer_name": job.employer_name,
"employer_domain": "jobs.example.org",
"location_text": job.raw_location,
"region": job.region,
"municipality": job.municipality,
"postal_code": job.postal_code,
"country": job.country,
"workplace_type": job.workplace_type,
"employment_types": job.employment_types,
"language": "nl",
"description_html": "<p>Test</p>",
"description_text": "test beschrijving",
"date_posted": datetime(2026, 1, 10, tzinfo=UTC),
"valid_through": datetime(2026, 6, 1, tzinfo=UTC),
"compensation": {},
"skills_required": [],
"skills_preferred": [],
"content_hash": job.content_hash,
"canonical_key": "x" * 64,
"evidence": [],
"raw": {},
}
values.update(overrides)
return CanonicalJobDraft(**values)
@pytest.mark.django_db
def test_resolver_merges_recruiter_alias_to_direct_source():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="a" * 64,
job_family="security",
content_hash="a" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
# Use an existing direct employer job as the resolver target.
target_job = JobPosting.objects.first()
decision = resolve_direct_employer_match(
_draft_for_test(target_job),
source=source,
)
assert decision.job == target_job
assert decision.reason == "resolved_direct_match"
assert decision.canonical_url == "https://jobs.example.org/vacatures/security-engineer"
@pytest.mark.django_db
def test_resolver_flags_conflicting_location_for_review():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="b" * 64,
job_family="security",
content_hash="b" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
location_text="Gent",
municipality="Gent",
region="Oost-Vlaanderen",
),
source=source,
)
assert decision.job is None
assert decision.reason == "review_direct_conflict"
@pytest.mark.django_db
def test_resolver_keeps_candidate_unmerged_when_similarity_is_conservative():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="c" * 64,
job_family="security",
content_hash="c" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="StaffingBoard",
source_type=Source.Type.ATS,
base_url="https://jobs.staffingboard.com/example",
domain="jobs.staffingboard.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
title="Data Analyst",
normalized_title="data analyst",
location_text="Brussel",
employer_name="Another Employer",
employer_domain="jobs.staffingboard.com",
),
source=source,
)
assert decision.job is None
assert decision.reason == "no_direct_resolution"
+45
View File
@@ -0,0 +1,45 @@
from apps.jobs.services.distance import CommuteEstimate, estimate_commute, haversine_km
from apps.jobs.services.features import extract_deterministic_features, term_ratio
def test_feature_extraction_and_distance_are_deterministic():
text = (
"Voor een gemeente zoeken we een consultant. Rijbewijs B en 5 jaar ervaring vereist. "
"De rol bevat helpdesk en telefonische support bij klanten."
)
features = extract_deterministic_features("Support Engineer", text)
assert features["support_ratio"] > 0
assert features["consultancy_ratio"] > 0
assert features["travel_ratio"] > 0
assert features["public_sector_signal"] > 0
assert features["experience_years_max"] == 5
assert term_ratio("", ["x"]) == 0
assert 60 < haversine_km(50.9307, 5.3325, 50.8503, 4.3517) < 80
def test_conservative_commute_estimate_is_marked_as_estimate():
result = estimate_commute(20.0)
assert isinstance(result, CommuteEstimate)
assert result.is_estimate
assert result.minutes > 0
assert result.source == "road"
def test_custom_commute_estimator_is_accepted():
class CustomEstimator:
name = "custom"
version = "test"
def estimate(self, distance_km: float) -> CommuteEstimate:
return CommuteEstimate(
minutes=int(distance_km * 2),
km=distance_km,
source=self.name,
source_version=self.version,
confidence=0.7,
is_estimate=False,
)
result = estimate_commute(12.0, estimator=CustomEstimator())
assert result.minutes == 24
assert result.source_version == "test"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from apps.jobs.models import Feedback
from apps.jobs.services.feedback import record_feedback
def test_learning_signals_wait_for_min_samples(profile, job, user):
first = record_feedback(user=user, job=job, action=Feedback.Action.INTERESTING)
second = record_feedback(user=user, job=job, action=Feedback.Action.INTERESTING)
first_metadata = first.metadata.get("learning", {})
second_metadata = second.metadata.get("learning", {})
assert first_metadata.get("status") == "queued"
assert first_metadata.get("reason_code") == "positive_feedback"
assert second_metadata.get("status") == "applied"
profile.refresh_from_db()
assert profile.weights["content"] == 31.0
def test_learning_skip_for_implicit_hide(profile, job, user):
feedback = record_feedback(user=user, job=job, action=Feedback.Action.HIDE)
metadata = feedback.metadata.get("learning", {})
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "implicit_hide_no_reason"
profile.refresh_from_db()
assert profile.weights["content"] == 30.0
def test_learning_marks_explicit_title_hide_as_nonlearning(profile, job, user):
feedback = record_feedback(
user=user, job=job, action=Feedback.Action.HIDE, reason="Titel past niet bij mij"
)
metadata = feedback.metadata.get("learning", {})
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "non_learning_title"
+226
View File
@@ -0,0 +1,226 @@
import httpx
import pytest
from django.test import override_settings
from apps.sources.services.fetcher import (
ContentRejectedError,
FetchError,
FetchTimeoutError,
PolicyBlockedError,
RateLimitedError,
_validate_connected_peer,
fetch_url,
)
from apps.sources.services.url_security import ValidatedUrl
PUBLIC_DNS = [(None, None, None, None, ("93.184.216.34", 443))]
def _patch_dns(monkeypatch):
monkeypatch.setattr(
"apps.sources.services.url_security.socket.getaddrinfo",
lambda *args, **kwargs: PUBLIC_DNS,
)
# validate_public_url captured the default resolver at definition time, so patch call site too.
original = __import__(
"apps.sources.services.url_security", fromlist=["validate_public_url"]
).validate_public_url
def validate(url, **kwargs):
return original(url, resolver=lambda *a, **k: PUBLIC_DNS, **kwargs)
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def test_fetcher_success_redirect_304_and_hash(monkeypatch):
_patch_dns(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/start":
return httpx.Response(302, headers={"location": "/job"})
return httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
client = httpx.Client(transport=httpx.MockTransport(handler))
document = fetch_url("https://example.org/start", client=client)
assert document.final_url == "https://example.org/job"
assert document.text == "Vacature"
assert len(document.sha256) == 64
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(304, headers={"etag": "x"}))
)
unchanged = fetch_url("https://example.org/job", client=client)
assert unchanged.status_code == 304
assert unchanged.content == b""
client.close()
def test_fetcher_respects_rate_limit_and_retry_after(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
429, headers={"retry-after": "45"}, content=b"too many requests"
)
)
)
with pytest.raises(RateLimitedError) as exc:
fetch_url("https://example.org/too-fast", client=client)
assert exc.value.retry_after_seconds == 45
client.close()
def test_fetcher_rejects_dns_rebinding(monkeypatch):
_patch_dns(monkeypatch)
calls = 0
def validate(url: str, **kwargs):
nonlocal calls
calls += 1
address = "93.184.216.34" if calls == 1 else "198.51.100.12"
return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=(address,))
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
)
)
with pytest.raises(FetchError, match="DNS-rebindcontrole"):
fetch_url("https://example.org/start", client=client)
client.close()
def test_production_transport_fails_closed_without_connected_peer():
validated = ValidatedUrl(
url="https://example.org/jobs",
hostname="example.org",
port=443,
addresses=("93.184.216.34",),
)
response = httpx.Response(200)
with pytest.raises(FetchError, match="Verbonden IP-adres"):
_validate_connected_peer(
response,
before=validated,
after=validated,
require_peer=True,
)
class _PeerStream:
def __init__(self, address: str) -> None:
self.address = address
def get_extra_info(self, key: str):
return (self.address, 443) if key == "server_addr" else None
def test_fetcher_accepts_public_cdn_rotation_when_connected_peer_is_public(monkeypatch):
calls = 0
def validate(url: str, **kwargs):
nonlocal calls
calls += 1
address = "150.171.109.35" if calls == 1 else "150.171.109.36"
return ValidatedUrl(url=url, hostname="cdn.example.org", port=443, addresses=(address,))
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def handler(request: httpx.Request) -> httpx.Response:
status_code = 302 if request.url.path == "/start" else 200
headers = {"location": "/jobs"} if status_code == 302 else {"content-type": "text/html"}
return httpx.Response(
status_code,
headers=headers,
content=b"" if status_code == 302 else b"Vacature",
extensions={"network_stream": _PeerStream("150.171.109.36")},
)
client = httpx.Client(transport=httpx.MockTransport(handler))
document = fetch_url("https://cdn.example.org/start", client=client)
assert document.status_code == 200
assert document.final_url == "https://cdn.example.org/jobs"
client.close()
def test_fetcher_rejects_private_connected_peer(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200,
headers={"content-type": "text/html"},
content=b"Vacature",
extensions={"network_stream": _PeerStream("10.0.0.5")},
)
)
)
with pytest.raises(FetchError, match="Niet-publiek verbonden IP-adres"):
fetch_url("https://example.org/jobs", client=client)
client.close()
def test_fetcher_rejects_policy_content_size_and_http(monkeypatch):
_patch_dns(monkeypatch)
with pytest.raises(PolicyBlockedError):
fetch_url("https://linkedin.com/jobs", client=httpx.Client())
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, headers={"content-type": "image/png"}, content=b"x")
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/image", client=client)
client.close()
with override_settings(FETCHER_MAX_BYTES=2):
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/plain"}, content=b"long"
)
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/large", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(500, content=b"error"))
)
with pytest.raises(FetchError, match="HTTP 500"):
fetch_url("https://example.org/error", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: (_ for _ in ()).throw(httpx.TimeoutException("timeout"))
)
)
with pytest.raises(FetchTimeoutError):
fetch_url("https://example.org/slow", client=client)
client.close()
def test_fetcher_rejects_redirect_without_location(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(302)))
with pytest.raises(FetchError, match="Location"):
fetch_url("https://example.org/start", client=client)
client.close()
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from apps.sources.adapters.generic_html import GenericHtmlAdapter
def test_generic_html_fallback_extracts_core_fields():
html = Path("fixtures/pages/sample_generic_job.html").read_text(encoding="utf-8")
result = GenericHtmlAdapter().extract(html, url="https://careers.example.net/jobs/1")
assert result.jobs[0].title == "Workplace Engineer"
assert result.jobs[0].employer_name == "Example Health"
assert "Microsoft Intune" in result.jobs[0].description_text
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import pytest
from django.core.management import CommandError
from apps.jobs.models import GeocodeLocationLookup
from apps.jobs.services.geocoding import (
CsvGeocodeProvider,
GeoPoint,
LocationMatch,
LocationMatchResult,
import_csv_geodata,
parse_belgian_location_query,
resolve_location,
validate_csv_geodata,
)
@pytest.mark.django_db
def test_parse_belgian_location_query():
postal, municipality = parse_belgian_location_query("Gent, 9000")
assert postal == "9000"
assert municipality == "gent"
postal, municipality = parse_belgian_location_query("1050 Ixelles")
assert postal == "1050"
assert municipality == "ixelles"
postal, municipality = parse_belgian_location_query("Brussel")
assert postal is None
assert municipality == "brussel"
@pytest.mark.django_db
def test_geocode_provider_resolves_postal_and_municipality_exactly():
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="postal",
query_value="9000",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.0500"),
longitude=Decimal("3.7300"),
confidence=Decimal("0.90"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="gent",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.0500"),
longitude=Decimal("3.7300"),
confidence=Decimal("0.90"),
)
provider = CsvGeocodeProvider(source_name="fixture", source_version="2026-01-01")
result = resolve_location("9000 Gent", provider)
assert result.ambiguous is False
assert result.location is not None
assert result.location.postal_code == "9000"
assert result.location.municipality == "Gent"
assert result.location.point == GeoPoint(latitude=51.05, longitude=3.73)
@pytest.mark.django_db
def test_geocode_provider_marks_ambiguous_as_unknown():
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="brussel",
postal_code="1000",
municipality="Brussel",
region="Brussels Hoofdstedelijk Gewest",
latitude=Decimal("50.8500"),
longitude=Decimal("4.3500"),
confidence=Decimal("0.85"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="brussel",
postal_code="1000-200",
municipality="Brussel",
region="Brussels Hoofdstedelijk Gewest",
latitude=Decimal("50.8400"),
longitude=Decimal("4.3400"),
confidence=Decimal("0.85"),
)
provider = CsvGeocodeProvider(source_name="fixture", source_version="2026-01-01")
result = resolve_location("Brussel", provider)
assert result.ambiguous is True
assert result.location is None
@pytest.mark.django_db
def test_validate_csv_geodata_flags_duplicates_and_invalid_coordinates(tmp_path: Path):
source = tmp_path / "geodata.csv"
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"9000,Gent,Vlaams-Brabant,51.05,3.74\n"
)
with pytest.raises(CommandError, match="dubbel record"):
validate_csv_geodata(source)
source.write_text(
"postal_code,municipality,region,latitude,longitude\n9000,Gent,Vlaams-Brabant,99,3.73\n"
)
with pytest.raises(CommandError, match="latitude buiten bereik"):
validate_csv_geodata(source)
@pytest.mark.django_db
def test_import_csv_geodata_is_atomic_without_replace(tmp_path: Path):
source = tmp_path / "geodata.csv"
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"1040,Brussel,Brussels Hoofdstedelijk Gewest,50.85,4.35\n"
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="manual",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="gent",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.05"),
longitude=Decimal("3.73"),
confidence=Decimal("1.00"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="manual",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="postal",
query_value="9000",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.05"),
longitude=Decimal("3.73"),
confidence=Decimal("1.00"),
)
with pytest.raises(CommandError, match="overschrijven zonder --replace"):
import_csv_geodata(source, source_name="fixture", source_version="manual")
assert GeocodeLocationLookup.objects.count() == 2
def test_resolve_location_keeps_ambiguous_unknown() -> None:
class StubProvider:
def resolve(self, query: str):
return [
LocationMatch(
postal_code="1000",
municipality="Brussel",
region="Brussels",
point=None,
confidence=0.85,
source="csv",
source_version="manual",
metadata={},
),
LocationMatch(
postal_code="1001",
municipality="Brussel",
region="Brussels",
point=None,
confidence=0.85,
source="csv",
source_version="manual",
metadata={},
),
]
result = resolve_location("Brussel", StubProvider())
assert result.ambiguous is True
assert isinstance(result, LocationMatchResult)
assert result.location is None
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from django.test import override_settings
from apps.core.health import readiness
pytestmark = pytest.mark.django_db
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
def test_readiness_checks_database_and_shared_cache():
result = readiness()
assert result.ok is True
assert result.checks == {"database": "ok", "cache": "ok"}
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
def test_readiness_reports_cache_failure(monkeypatch):
def fail_cache(*args, **kwargs):
raise ConnectionError
monkeypatch.setattr("apps.core.health.cache.set", fail_cache)
result = readiness()
assert result.ok is False
assert result.checks["database"] == "ok"
assert result.checks["cache"] == "error:ConnectionError"
+41
View File
@@ -0,0 +1,41 @@
from pathlib import Path
import pytest
from scripts.benchmark_import import UnsafeBenchmarkEnvironment, validate_benchmark_identity
def _database(name: str, host: str = "127.0.0.1") -> dict[str, str]:
return {
"ENGINE": "django.db.backends.postgresql",
"NAME": name,
"HOST": host,
}
def test_benchmark_identity_accepts_only_isolated_postgres() -> None:
validate_benchmark_identity(
_database("vacatureradar_bench_release_0314"), "https://benchmark.invalid"
)
@pytest.mark.parametrize(
("database", "public_url"),
[
(_database("vacatureradar"), "https://benchmark.invalid"),
(
_database("vacatureradar_bench_release", "database.internal"),
"https://benchmark.invalid",
),
(_database("vacatureradar_bench_release"), "https://vacatureradar.itworx.tech"),
(
{"ENGINE": "django.db.backends.sqlite3", "NAME": Path("bench.sqlite3"), "HOST": ""},
"https://benchmark.invalid",
),
],
)
def test_benchmark_identity_rejects_unsafe_targets(
database: dict[str, object], public_url: str
) -> None:
with pytest.raises(UnsafeBenchmarkEnvironment):
validate_benchmark_identity(database, public_url)
+15
View File
@@ -0,0 +1,15 @@
import json
from pathlib import Path
from apps.sources.adapters.jsonld import JsonLdJobPostingAdapter
def test_extracts_sample_jobposting():
html = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
expected = json.loads(Path("fixtures/golden/sample_jsonld_job.json").read_text())
result = JsonLdJobPostingAdapter().extract(html, url="https://jobs.example.org/source")
assert result.confidence >= 0.9
assert len(result.jobs) == 1
job = result.jobs[0]
for key, value in expected.items():
assert getattr(job, key) == value
+123
View File
@@ -0,0 +1,123 @@
import pytest
from cryptography.fernet import Fernet
from django.core.exceptions import ValidationError
from django.test import override_settings
from apps.sources.models import MailboxConnection
from apps.sources.services.imap_import import poll_imap_mailbox
from apps.sources.services.mailbox_connections import (
MailboxCredentialError,
decrypt_mailbox_password,
encrypt_mailbox_password,
rotate_mailbox_credentials,
)
from apps.sources.services.url_security import UnsafeUrlError
def test_mailbox_password_is_encrypted_and_supports_key_rotation():
old_key = Fernet.generate_key().decode("ascii")
new_key = Fernet.generate_key().decode("ascii")
with override_settings(MAILBOX_CREDENTIAL_KEYS=[old_key]):
ciphertext = encrypt_mailbox_password("app-password-value")
connection = MailboxConnection(encrypted_password=ciphertext)
assert "app-password-value" not in ciphertext
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key, old_key]):
assert decrypt_mailbox_password(connection) == "app-password-value"
rotated = encrypt_mailbox_password("replacement-value")
assert Fernet(new_key.encode("ascii")).decrypt(rotated.encode("ascii")) == b"replacement-value"
def test_mailbox_password_fails_closed_without_valid_key():
with (
override_settings(MAILBOX_CREDENTIAL_KEYS=[]),
pytest.raises(MailboxCredentialError, match="niet geconfigureerd"),
):
encrypt_mailbox_password("never-stored")
with (
override_settings(MAILBOX_CREDENTIAL_KEYS=["invalid"]),
pytest.raises(MailboxCredentialError, match="ongeldige sleutel"),
):
encrypt_mailbox_password("never-stored")
@pytest.mark.parametrize("host", ["", "https://imap.example.org", "imap.example.org/path", "a b"])
def test_custom_mailbox_host_rejects_non_hostname_values(host):
connection = MailboxConnection(
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.CUSTOM,
custom_host=host,
port=993,
username="mailbox@example.invalid",
encrypted_password="ciphertext",
)
with pytest.raises(ValidationError):
connection.clean()
@pytest.mark.parametrize("port", [0, 65536])
def test_mailbox_rejects_invalid_port(port):
connection = MailboxConnection(
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
port=port,
username="mailbox@example.invalid",
encrypted_password="ciphertext",
)
with pytest.raises(ValidationError):
connection.clean()
@pytest.mark.django_db
def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
old_key = Fernet.generate_key().decode("ascii")
new_key = Fernet.generate_key().decode("ascii")
with override_settings(MAILBOX_CREDENTIAL_KEYS=[old_key]):
ciphertext = encrypt_mailbox_password("rotate-me")
connection = MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
username="rotate@example.invalid",
encrypted_password=ciphertext,
)
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key, old_key]):
assert rotate_mailbox_credentials() == 1
connection.refresh_from_db()
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key]):
assert decrypt_mailbox_password(connection) == "rotate-me"
@pytest.mark.django_db
def test_rotation_skips_outlook_oauth_connections(user):
MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.INDEED,
provider=MailboxConnection.Provider.OUTLOOK,
username="vacatureradar@example.invalid",
encrypted_password="",
)
assert rotate_mailbox_credentials() == 0
@pytest.mark.django_db
def test_private_imap_host_is_blocked_before_connection(user):
key = Fernet.generate_key().decode("ascii")
with override_settings(MAILBOX_CREDENTIAL_KEYS=[key]):
connection = MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.CUSTOM,
custom_host="127.0.0.1",
username="private@example.invalid",
encrypted_password=encrypt_mailbox_password("never-sent"),
)
with pytest.raises(UnsafeUrlError, match="Niet-publiek"):
poll_imap_mailbox(
connection,
client_factory=lambda *args, **kwargs: pytest.fail(
"Private IMAP-host had niet geconnecteerd mogen worden"
),
)
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import pytest
from apps.sources.models import Source, SourceRun
from apps.sources.services.fetcher import FetchedDocument
from apps.sources.services.manual_import import ManualImportError, import_manual_source
def test_manual_import_url_uses_fetch_and_succeeds(monkeypatch, user):
def fake_fetch(*_args, **_kwargs):
return FetchedDocument(
requested_url="https://jobs.example.org/jobs",
final_url="https://jobs.example.org/jobs",
status_code=200,
headers={
"content-type": "text/html; charset=utf-8",
"etag": "abc",
"last-modified": "Mon, 01 Jan 2000 00:00:00 GMT",
},
content=b"<html><body>vacature</body></html>",
)
def fake_process(document):
return {
"extracted": 2,
"created": 1,
"updated": 1,
"duplicates": 0,
"parser": "manual-test",
"warnings": [],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fake_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
summary = import_manual_source(actor=user, source_url="https://jobs.example.org/jobs")
source = Source.objects.get(pk=summary.source_id)
assert summary.mode == "url"
assert summary.extracted_count == 2
assert summary.created_count == 1
assert source.source_type == Source.Type.MANUAL
assert source.domain == "jobs.example.org"
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SUCCESS).exists()
def test_manual_import_paste_text_sanitizes_and_skips_fetch(monkeypatch, user):
def fail_fetch(*_args, **_kwargs):
raise AssertionError("Fetcher should not be called for pasted text.")
def fake_process(document):
return {
"extracted": 0,
"created": 0,
"updated": 0,
"duplicates": 0,
"parser": "manual-test",
"warnings": ["noop"],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fail_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
summary = import_manual_source(
actor=user, pasted_text="<script>alert('x')</script>\nVacaturetekst"
)
source = Source.objects.get(pk=summary.source_id)
assert summary.mode == "paste"
assert source.source_type == Source.Type.MANUAL
assert source.base_url.startswith("https://manual-")
def test_manual_import_rejects_denied_domain(user):
with pytest.raises(ManualImportError):
import_manual_source(actor=user, source_url="https://linkedin.com/jobs/123")
def test_manual_import_rejects_paste_payload_when_too_large(user, settings):
settings.MANUAL_IMPORT_PASTE_MAX_BYTES = 12
huge_text = "a" * 20
with pytest.raises(ManualImportError):
import_manual_source(actor=user, pasted_text=huge_text)
+108
View File
@@ -0,0 +1,108 @@
import base64
import pytest
from django.test import override_settings
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.notifications.backends import Microsoft365OAuthEmailBackend
@override_settings(
M365_TENANT_ID="tenant-id",
M365_CLIENT_ID="client-id",
M365_CLIENT_SECRET="client-secret",
M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default",
)
def test_app_only_token_uses_tenant_authority_and_exchange_scope():
calls = {}
class Client:
def acquire_token_for_client(self, *, scopes):
calls["scopes"] = scopes
return {"access_token": "access-token"}
def factory(client_id, *, authority, client_credential):
calls.update(
client_id=client_id,
authority=authority,
client_credential=client_credential,
)
return Client()
assert acquire_exchange_access_token(client_factory=factory) == "access-token"
assert calls == {
"client_id": "client-id",
"authority": "https://login.microsoftonline.com/tenant-id",
"client_credential": "client-secret",
"scopes": ["https://outlook.office365.com/.default"],
}
@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="")
def test_missing_oauth_configuration_fails_without_secret_material():
with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error:
acquire_exchange_access_token()
assert "secret" not in str(error.value).lower()
def test_xoauth2_payload_is_encoded_exactly():
encoded = xoauth2_b64("mailbox@example.invalid", "token-value")
assert base64.b64decode(encoded) == (
b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01"
)
@override_settings(
M365_MAILBOX_USER="vacatureradar@example.invalid",
EMAIL_HOST="smtp.office365.com",
EMAIL_PORT=587,
DEFAULT_FROM_EMAIL="VacatureRadar <vacatureradar@example.invalid>",
M365_SMTP_TIMEOUT_SECONDS=10,
)
def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch):
calls = []
class SMTP:
def __init__(self, host, port, *, timeout):
calls.append(("connect", host, port, timeout))
def __enter__(self):
return self
def __exit__(self, *args):
return None
def ehlo(self):
calls.append(("ehlo",))
def starttls(self, *, context):
calls.append(("starttls", bool(context)))
def docmd(self, command, argument):
calls.append((command, argument))
return 235, b"ok"
def sendmail(self, sender, recipients, message):
calls.append(("sendmail", sender, recipients, message))
monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP)
monkeypatch.setattr(
"apps.notifications.backends.acquire_exchange_access_token",
lambda: "token-value",
)
from django.core.mail import EmailMessage as DjangoEmailMessage
django_message = DjangoEmailMessage(
subject="Test",
body="test",
from_email="vacatureradar@example.invalid",
to=["recipient@example.invalid"],
)
assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1
assert calls[0] == ("connect", "smtp.office365.com", 587, 10)
assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls)
assert any(call[0] == "sendmail" for call in calls)
+45
View File
@@ -0,0 +1,45 @@
from unittest.mock import Mock, patch
import pytest
from django.http import HttpResponse
from django.test import RequestFactory
from apps.core.middleware import MigrationMaintenanceMiddleware
@pytest.mark.parametrize("method,path,allowed", [
("GET", "/health/ready/", True),
("HEAD", "/health/live/", True),
("POST", "/health/ready/", False),
("GET", "/health/ready", False),
("GET", "/", False),
("POST", "/accounts/login/", False),
("OPTIONS", "/health/live/", False),
])
def test_held_candidate_only_allows_exact_safe_health(method, path, allowed):
downstream = Mock(return_value=HttpResponse("ok"))
with patch("apps.core.middleware.hold_active", return_value=True):
response = MigrationMaintenanceMiddleware(downstream)(
RequestFactory().generic(method, path)
)
assert response.status_code == (200 if allowed else 503)
assert downstream.called is allowed
if not allowed:
assert response["Cache-Control"] == "no-store"
@pytest.mark.parametrize("failure", [PermissionError(), RuntimeError()])
def test_marker_errors_block_ordinary_traffic(failure):
downstream = Mock()
with patch("apps.core.middleware.hold_active", side_effect=failure):
response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().get("/"))
assert response.status_code == 503
downstream.assert_not_called()
def test_normal_runtime_unchanged_and_middleware_is_first(settings):
assert settings.MIDDLEWARE[0] == "apps.core.middleware.MigrationMaintenanceMiddleware"
downstream = Mock(return_value=HttpResponse("ok"))
with patch("apps.core.middleware.hold_active", return_value=False):
response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().post("/"))
assert response.status_code == 200
+44
View File
@@ -0,0 +1,44 @@
import importlib.util
from pathlib import Path
import pytest
spec = importlib.util.spec_from_file_location(
"migration_worker_gate",
Path(__file__).resolve().parents[2] / "scripts/migration_worker_gate.py",
)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
@pytest.mark.parametrize("role", ["worker", "scheduler"])
def test_hold_prevents_outbound_process_until_release(role):
states = iter([True, True, False])
events = []
gate.start(
role,
check=lambda: next(states),
sleep=lambda seconds: events.append("wait"),
execute=lambda executable, args: events.append(args),
)
assert events == ["wait", "wait", gate.COMMANDS[role]]
def test_normal_startup_and_missing_hold(tmp_path):
assert not gate.hold_active(tmp_path / "absent")
events = []
gate.start("worker", check=lambda: False, execute=lambda executable, args: events.append(args))
assert events == [gate.COMMANDS["worker"]]
def test_hold_exists_and_unsupported_marker_fails_closed(tmp_path):
marker = tmp_path / "hold"
marker.write_text("fixture-attempt")
assert gate.hold_active(marker)
with pytest.raises(RuntimeError):
gate.hold_active(tmp_path)
def test_unknown_role_never_executes():
with pytest.raises(ValueError):
gate.start("shell", execute=lambda *args: pytest.fail("unreviewed command"))
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from django.test import RequestFactory, override_settings
from apps.core.network import client_ip
def _request(remote: str, forwarded: str = ""):
request = RequestFactory().get("/")
request.META["REMOTE_ADDR"] = remote
if forwarded:
request.META["HTTP_X_FORWARDED_FOR"] = forwarded
return request
@override_settings(TRUSTED_PROXY_CIDRS=[])
def test_client_ip_ignores_untrusted_forwarded_header():
assert client_ip(_request("203.0.113.20", "198.51.100.7")) == "203.0.113.20"
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
def test_client_ip_uses_forwarded_chain_only_from_trusted_proxy():
request = _request("172.18.0.4", "198.51.100.7, 172.18.0.3")
assert client_ip(request) == "198.51.100.7"
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
def test_client_ip_skips_invalid_forwarded_values():
request = _request("172.18.0.4", "invalid, 198.51.100.9")
assert client_ip(request) == "198.51.100.9"
+15
View File
@@ -0,0 +1,15 @@
from apps.jobs.services.normalization import (
infer_language,
normalize_employment_types,
normalize_title,
)
def test_title_and_employment_normalization():
assert normalize_title("Senior System Engineer (m/v/x) - Fulltime") == "senior system engineer"
assert normalize_employment_types(["FULL_TIME", "vast"]) == ["full_time", "permanent"]
def test_language_detection_is_conservative():
assert infer_language("Je beheert de infrastructuur en werkt met een team") == "nl"
assert infer_language("You manage the infrastructure and work with a team") == "en"
+74
View File
@@ -0,0 +1,74 @@
from decimal import Decimal
import pytest
from django.core.exceptions import ValidationError
from apps.profiles.models import ProfileRevision, SearchProfile
from apps.profiles.services import apply_feedback_delta, save_profile_revision
@pytest.mark.django_db
def test_profile_validation_snapshot_activation_and_revisions(user, profile):
profile.full_clean()
snapshot = profile.snapshot()
assert snapshot["home_municipality"] == "Hasselt"
assert snapshot["home_latitude"] == pytest.approx(50.9307)
assert snapshot["experience_years"] == 0
assert "seniority" not in profile.weights
assert sum(profile.weights.values()) == 100
other = SearchProfile.objects.create(user=user, name="Tweede", is_active=False)
other.activate()
profile.refresh_from_db()
other.refresh_from_db()
assert other.is_active is True
assert profile.is_active is False
revision = save_profile_revision(other, reason="test")
assert revision.version == 1
assert ProfileRevision.objects.filter(profile=other).count() == 1
second = save_profile_revision(other, reason="test-2")
assert second.version == 2
other.refresh_from_db()
assert other.version == 2
@pytest.mark.django_db
def test_feedback_delta_is_bounded_and_can_be_disabled(profile):
apply_feedback_delta(profile, "content", 100)
profile.refresh_from_db()
assert profile.weights["content"] == 40.0
apply_feedback_delta(profile, "content", -100)
profile.refresh_from_db()
assert profile.weights["content"] == 0.0
profile.learning_enabled = False
profile.weights["skills"] = 20.0
profile.save()
apply_feedback_delta(profile, "skills", 5)
profile.refresh_from_db()
assert profile.weights["skills"] == 20.0
@pytest.mark.parametrize(
("field", "value"),
[
("weights", {"content": "not-a-number"}),
("weights", {"content": -1}),
("home_latitude", Decimal("91")),
("home_longitude", Decimal("181")),
],
)
@pytest.mark.django_db
def test_profile_rejects_invalid_values(profile, field, value):
setattr(profile, field, value)
with pytest.raises(ValidationError):
profile.full_clean()
@pytest.mark.django_db
def test_profile_rejects_inverted_thresholds(profile):
profile.recommendation_threshold = 91
profile.top_match_threshold = 90
with pytest.raises(ValidationError):
profile.full_clean()
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import normalize_public_base_url
from scripts.configure_public_url import ConfigurationError, configure_env, normalize_public_url
@pytest.mark.parametrize(
("raw", "origin", "host"),
[
(
"https://vacatureradar.itworx.tech/",
"https://vacatureradar.itworx.tech",
"vacatureradar.itworx.tech",
),
("https://jobs.example.be:8443", "https://jobs.example.be:8443", "jobs.example.be"),
("http://127.0.0.1:1226", "http://127.0.0.1:1226", "127.0.0.1"),
],
)
def test_public_url_normalization(raw, origin, host):
assert normalize_public_base_url(raw) == (origin, host)
assert normalize_public_url(raw) == (origin, host)
@pytest.mark.parametrize(
"raw",
[
"vacatureradar.itworx.tech",
"ftp://vacatureradar.itworx.tech",
"https://user:secret@example.org",
"https://example.org/app",
"https://example.org?debug=1",
],
)
def test_public_url_rejects_unsafe_or_unsupported_values(raw):
with pytest.raises((ImproperlyConfigured, ConfigurationError)):
normalize_public_base_url(raw)
def test_configure_public_url_updates_only_public_security_settings(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text(
"DJANGO_SECRET_KEY=keep-this-secret\n"
"POSTGRES_PASSWORD=keep-this-password\n"
"DJANGO_ALLOWED_HOSTS=vacatureradar.local,localhost\n"
"DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226\n",
encoding="utf-8",
)
changed = configure_env(
env_file,
"https://vacatureradar.itworx.tech/",
hsts_seconds=300,
cache_url="redis://127.0.0.1:6379/1",
trusted_proxy_cidrs=["172.18.0.0/16"],
)
content = env_file.read_text(encoding="utf-8")
assert "PUBLIC_BASE_URL=https://vacatureradar.itworx.tech" in content
assert "DJANGO_DEBUG=0" in content
assert "vacatureradar.itworx.tech" in content
assert (
"DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226,https://vacatureradar.itworx.tech"
) in content
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
assert "DJANGO_SECRET_KEY=keep-this-secret" in content
assert "POSTGRES_PASSWORD=keep-this-password" in content
if os.name != "nt":
assert env_file.stat().st_mode & 0o777 == 0o600
assert "DJANGO_SECRET_KEY" not in changed
def test_configure_public_url_rejects_plain_http(tmp_path: Path):
with pytest.raises(ConfigurationError, match="HTTPS"):
configure_env(tmp_path / ".env", "http://vacatureradar.example.be")
def test_public_base_url_populates_django_host_and_csrf_settings():
environment = os.environ.copy()
environment.update(
{
"DJANGO_SECRET_KEY": "aB3!" * 16,
"DJANGO_DEBUG": "0",
"PUBLIC_BASE_URL": "https://vacatureradar.itworx.tech",
"DEMO_MODE_ENABLED": "0",
"SECURE_HSTS_SECONDS": "300",
}
)
result = subprocess.run( # noqa: S603 - fixed interpreter and static assertion program
[
sys.executable,
"-c",
(
"import config.settings as settings; "
"assert 'vacatureradar.itworx.tech' in settings.ALLOWED_HOSTS; "
"assert 'https://vacatureradar.itworx.tech' "
"in settings.CSRF_TRUSTED_ORIGINS; "
"assert settings.DEBUG is False; "
"assert settings.SESSION_COOKIE_SECURE is True; "
"assert settings.CSRF_COOKIE_SECURE is True; "
"assert settings.SECURE_SSL_REDIRECT is True"
),
],
cwd=Path(__file__).resolve().parents[2],
env=environment,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_configure_public_url_is_idempotent(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text("DJANGO_ALLOWED_HOSTS=localhost\n", encoding="utf-8")
for _ in range(2):
configure_env(env_file, "https://vacatureradar.example.be")
content = env_file.read_text(encoding="utf-8")
assert content.count("PUBLIC_BASE_URL=") == 1
assert content.count("vacatureradar.example.be") == 3
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from django.core.cache import cache
from django.test import RequestFactory
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
@pytest.fixture(autouse=True)
def _clear_rate_limit_cache():
cache.clear()
yield
cache.clear()
def _post_request(payload: dict[str, str], *, username: str | None = None):
factory = RequestFactory()
request = factory.post("/login/", data=payload)
request.META["REMOTE_ADDR"] = "203.0.113.10"
if username is None:
request.user = SimpleNamespace(is_authenticated=False, is_anonymous=True)
else:
request.user = SimpleNamespace(is_authenticated=True, pk=42)
payload["username"] = username
request.user.username = username
return request
def test_rate_limit_blocks_after_max_attempts_and_reports_block_state():
request = _post_request({"username": "alice"}, username="alice")
state = is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 0
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 1
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert state.is_blocked
assert state.remaining_seconds > 0
assert is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
).is_blocked
def test_rate_limit_clear_resets_state_for_authenticated_identity():
request = _post_request({"username": "alice"}, username="alice")
state = register_rate_limit_failure(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert state.is_blocked
clear_rate_limit(request, namespace="login")
state = is_rate_limited(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert not state.is_blocked
assert state.attempts == 0
+253
View File
@@ -0,0 +1,253 @@
from pathlib import Path
import pytest
from apps.sources.adapters.kempen import KempenEmployerAdapter
from apps.sources.adapters.mol_region import MolRegionEmployerAdapter
from apps.sources.adapters.regional import CordaCampusAdapter, LocalEmployerListingAdapter
def test_corda_adapter_extracts_local_employer_cards_and_rejects_off_domain_links():
content = Path("fixtures/pages/corda-campus-jobs.html").read_text(encoding="utf-8")
result = CordaCampusAdapter().extract(
content,
url="https://www.cordacampus.com/jobs/",
)
assert result.parser_key == "regional-corda-campus"
assert len(result.jobs) == 2
assert {job.title for job in result.jobs} == {"System Engineer", "Data Engineer"}
assert {job.employer_name for job in result.jobs} == {
"Voorbeeld IT",
"Voorbeeld Data",
}
assert all(job.region == "Limburg" for job in result.jobs)
assert all("evil.example" not in job.url for job in result.jobs)
def test_corda_adapter_does_not_capture_individual_detail_pages():
result = CordaCampusAdapter().extract(
"<h1>System Engineer</h1>",
url="https://www.cordacampus.com/job/system-engineer/",
)
assert result.jobs == []
assert result.confidence == 0.0
@pytest.mark.parametrize(
("fixture_name", "url", "expected_title", "expected_employer"),
[
(
"aca-group-jobs.html",
"https://acagroup.be/en/jobs",
"AWS Cloud & DevOps Engineer",
"ACA Group",
),
(
"xplore-group-jobs.html",
"https://www.xploregroup.be/en/jobs",
"Platform Engineer",
"Xplore Group",
),
(
"uhasselt-jobs.html",
"https://www.uhasselt.be/vacatures",
"Expert systeembeheerder (100%)",
"Universiteit Hasselt",
),
(
"pxl-jobs.html",
"https://ses.pxl.be/",
"Lector netwerkbeheer",
"Hogeschool PXL",
),
(
"zol-jobs.html",
"https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE",
"(Senior) IT Engineer Infrastructuur",
"Ziekenhuis Oost-Limburg",
),
],
)
def test_local_employer_adapter_extracts_reviewed_routes_only(
fixture_name, url, expected_title, expected_employer
):
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
result = LocalEmployerListingAdapter().extract(content, url=url)
assert result.parser_key == "regional-local-employers"
assert len(result.jobs) == 1
assert result.jobs[0].title == expected_title
assert result.jobs[0].employer_name == expected_employer
assert result.jobs[0].region == "Limburg"
assert "evil.example" not in result.jobs[0].url
def test_local_employer_adapter_fails_closed_for_unreviewed_host_or_path():
adapter = LocalEmployerListingAdapter()
assert adapter.extract("<h3>Fake</h3>", url="https://evil.example/jobs").jobs == []
assert (
adapter.extract(
"<h3>Detail</h3>",
url="https://www.uhasselt.be/vacatures/detail/3999",
).jobs
== []
)
@pytest.mark.parametrize(
("fixture_name", "url", "expected_title", "expected_employer"),
[
(
"sck-cen-jobs.html",
"https://www.sckcen.be/nl/carriere/vacatures",
"Automatisatie Ingenieur PLC & OT-netwerken",
"SCK CEN",
),
(
"cipal-schaubroeck-jobs.html",
"https://cipalschaubroeck.teamtailor.com/jobs",
"Network & Security Engineer",
"Cipal Schaubroeck",
),
(
"vanroey-jobs.html",
"https://www.vanroey.be/en/job-overview/",
"Network Engineer",
"VanRoey",
),
(
"netropolix-jobs.html",
"https://netropolix.recruitee.com/",
"System Engineer - Geel",
"NTX (Netropolix)",
),
(
"thomas-more-jobs.html",
"https://jobpage.cvwarehouse.com/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE",
"Een medewerker lokale IT - Campus Geel",
"Thomas More",
),
],
)
def test_mol_region_adapter_extracts_only_reviewed_local_cards(
fixture_name, url, expected_title, expected_employer
):
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
result = MolRegionEmployerAdapter().extract(content, url=url)
assert result.parser_key == "regional-mol-employers"
assert result.warnings == []
assert len(result.jobs) == 1
assert result.jobs[0].title == expected_title
assert result.jobs[0].employer_name == expected_employer
assert result.jobs[0].region == "Antwerpen"
assert "evil.example" not in result.jobs[0].url
def test_mol_region_adapter_fails_closed_for_unreviewed_route_and_company():
adapter = MolRegionEmployerAdapter()
assert adapter.extract("<h1>Fake</h1>", url="https://evil.example/jobs").jobs == []
assert (
adapter.extract(
"<h1>Detail</h1>",
url="https://www.sckcen.be/nl/carriere/vacatures/detail",
).jobs
== []
)
@pytest.mark.parametrize(
("fixture_name", "url", "expected_title", "expected_employer", "expected_postal"),
[
(
"ziekenhuis-geel-jobs.html",
"https://ziekenhuisgeel.careersite.be/nl/vacatures",
"ICT Support Engineer",
"Ziekenhuis Geel",
"2440",
),
(
"stad-geel-jobs.html",
"https://geel.hro.be/",
"Administratief medewerker digitale dienstverlening",
"Lokaal bestuur Geel",
"2440",
),
(
"stad-turnhout-jobs.html",
"https://jobs.turnhout.be/",
"Coördinator digitale projecten",
"Stad Turnhout",
"2300",
),
(
"renotec-geel-jobs.html",
"https://jobs.renotec.be/nl/alle-jobs/",
"Technisch commercieel verantwoordelijke",
"Group Renotec",
"2440",
),
(
"ravago-kempen-jobs.html",
"https://ravago.softgarden.io/en/vacancies",
"SAP Operations Analyst",
"Ravago",
"2370",
),
(
"sanofi-geel-jobs.html",
"https://jobs.sanofi.com/en/belgium",
"Maintenance Engineer",
"Sanofi",
"2440",
),
(
"daf-westerlo-jobs.html",
"https://www.daf.com/nl-nl/werken-bij-daf/vacatures",
"Technieker Facilitaire Diensten",
"DAF Trucks",
"2260",
),
],
)
def test_kempen_adapter_extracts_only_reviewed_local_cards(
fixture_name, url, expected_title, expected_employer, expected_postal
):
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
result = KempenEmployerAdapter().extract(content, url=url)
assert result.parser_key == "regional-kempen-employers"
assert result.warnings == []
assert len(result.jobs) == 1
assert result.jobs[0].title == expected_title
assert result.jobs[0].employer_name == expected_employer
assert result.jobs[0].postal_code == expected_postal
assert "evil.example" not in result.jobs[0].url
def test_kempen_adapter_fails_closed_for_unreviewed_route():
adapter = KempenEmployerAdapter()
assert adapter.extract("<h1>Fake</h1>", url="https://evil.example/jobs").jobs == []
assert (
adapter.extract(
"<h1>Detail</h1>",
url="https://jobs.sanofi.com/en/search-jobs",
).jobs
== []
)
assert (
adapter.extract(
"<h1>Andere werkgever</h1>",
url="https://jobpage.cvwarehouse.com/?companyGuid=other&lang=nl-BE",
).jobs
== []
)
+74
View File
@@ -0,0 +1,74 @@
import pytest
from apps.jobs.models import JobPosting
from apps.jobs.services.relevance import (
assess_it_relevance,
it_relevance_query,
profile_requires_it_focus,
)
def test_title_led_it_relevance_accepts_clear_it_roles():
for title in (
"Modern Workplace Engineer",
"Product owner ICT (100%)",
"Functioneel Analist",
"Senior .Net Developer",
"Senior Mobile Developer",
"Deeltijds docent informatica, networking & security",
):
assessment = assess_it_relevance(title)
assert assessment.relevant, title
assert assessment.signals
def test_title_led_it_relevance_rejects_non_it_and_ambiguous_roles():
for title in (
"Technieker Facilitaire Diensten",
"Spontane sollicitatie - logopedist",
"Doctoraatsbursaal trombo-inflammatie bij hemodialyse",
"Stafmedewerker Onderwijs faculteit Rechten",
"Project Manager",
"Business Developer",
"IT Recruitment Specialist",
):
assessment = assess_it_relevance(title)
assert not assessment.relevant, title
assert assessment.reason
def test_ambiguous_field_role_requires_strong_it_context():
accepted = assess_it_relevance(
"Field Service Engineer",
"Installatie van Microsoft 365, Windows Server en netwerkswitches bij klanten.",
)
rejected = assess_it_relevance(
"Field Service Engineer",
"Onderhoud en herstelling van industriële HVAC- en koelinstallaties.",
)
assert accepted.relevant
assert "vacaturetekst" in accepted.reason
assert not rejected.relevant
assert "Ambigue technische functietitel" in rejected.reason
@pytest.mark.django_db
def test_database_it_query_matches_contextual_assessment(job):
job.original_title = "Field Service Engineer"
job.description_text = "Implementeer Intune, Windows Server en VPN-oplossingen bij klanten."
job.save(update_fields=["original_title", "description_text"])
assert JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
job.description_text = "Onderhoud industriële pompen en productielijnen."
job.save(update_fields=["description_text"])
assert not JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
def test_active_it_profile_requires_deterministic_it_focus(profile):
assert profile_requires_it_focus(profile)
profile.name = "Algemeen profiel"
profile.desired_titles = ["verpleegkundige"]
profile.desired_skills = []
assert not profile_requires_it_focus(profile)
+27
View File
@@ -0,0 +1,27 @@
from django.core.cache import cache
from django.test import override_settings
from apps.core.services.automation import _runtime_heartbeat_state
from apps.core.tasks import RUNTIME_HEARTBEAT_KEY, record_runtime_heartbeat
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
def test_runtime_heartbeat_reports_real_worker_execution():
cache.delete(RUNTIME_HEARTBEAT_KEY)
assert _runtime_heartbeat_state()["healthy"] is False
timestamp = record_runtime_heartbeat()
state = _runtime_heartbeat_state()
assert cache.get(RUNTIME_HEARTBEAT_KEY) == timestamp
assert state["healthy"] is True
assert state["label"].startswith("Actief")
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
def test_runtime_heartbeat_is_honest_in_eager_mode():
assert _runtime_heartbeat_state() == {
"label": "Eager/lokaal",
"healthy": True,
"observed_at": None,
}
+85
View File
@@ -0,0 +1,85 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.sources.models import Source, SourceLease
from apps.sources.services.scheduling import (
acquire_source_lease,
calculate_failure_backoff_seconds,
release_source_lease,
)
@pytest.mark.django_db
def test_source_lease_blocks_concurrent_workers_and_recovers_when_expired(source):
now = timezone.now()
lease_a = acquire_source_lease(source_id=source.pk, worker_token="worker-a", now=now)
assert lease_a is not None
assert acquire_source_lease(source_id=source.pk, worker_token="worker-b", now=now) is None
assert release_source_lease(source_id=source.pk, worker_token="worker-a", now=now)
lease_b = acquire_source_lease(
source_id=source.pk,
worker_token="worker-b",
now=now + timedelta(seconds=source.minimum_interval_seconds),
)
assert lease_b is not None
@pytest.mark.django_db
def test_source_lease_reuses_expired_lease(db, source):
now = timezone.now()
SourceLease.objects.create(
source=source,
token="expired-worker",
worker_id="expired-worker",
expires_at=now - timedelta(minutes=1),
)
lease = acquire_source_lease(source_id=source.pk, worker_token="recovery-worker", now=now)
assert lease is not None
assert lease.worker_id == "recovery-worker"
@pytest.mark.django_db
def test_source_lease_allows_origin_concurrency(db):
shared_domain = "shared.example.org"
source_a = Source.objects.create(
name="Source A",
source_type=Source.Type.EMPLOYER,
base_url="https://shared.example.org/jobs/",
domain=shared_domain,
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
max_concurrency=2,
)
source_b = Source.objects.create(
name="Source B",
source_type=Source.Type.RSS,
base_url="https://shared.example.org/careers/",
domain=shared_domain,
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
max_concurrency=2,
)
now = timezone.now()
lease_a = acquire_source_lease(source_id=source_a.pk, worker_token="worker-a", now=now)
lease_b = acquire_source_lease(source_id=source_b.pk, worker_token="worker-b", now=now)
assert lease_a is not None
assert lease_b is not None
@pytest.mark.django_db
def test_failure_backoff_uses_retry_after_and_bounds():
source = Source(
name="Temp",
source_type=Source.Type.EMPLOYER,
base_url="https://retry.example.org/",
domain="retry.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
source.save()
backoff = calculate_failure_backoff_seconds(source, failure_count=2, retry_after_seconds=120)
assert backoff >= 120
assert backoff <= 3600 + 45
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from apps.jobs.models import AiAnalysisCache, Employer, JobPosting, ScoreRun
from apps.jobs.services import scoring
from apps.jobs.services.ai import AiAnalysis
from apps.jobs.services.scoring import calculate_score
@pytest.fixture
def matching_job(db):
employer = Employer.objects.create(
name="Example Public IT",
normalized_name="example public it",
domain="jobs.example.org",
is_direct_employer=True,
)
return JobPosting.objects.create(
employer=employer,
original_title="Infrastructure Engineer",
normalized_title="infrastructure engineer",
canonical_url="https://jobs.example.org/jobs/1",
canonical_key="a" * 64,
content_hash="b" * 64,
description_text=(
"Beheer Microsoft 365 en VMware. Hybride werk en beperkte tweedelijnssupport."
),
raw_location="Hasselt, Limburg",
region="Limburg",
municipality="Hasselt",
postal_code="3500",
latitude=Decimal("50.930700"),
longitude=Decimal("5.332500"),
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time", "permanent"],
skills_required=["Microsoft 365", "VMware"],
analysis_features={"support_ratio": 0.1, "public_sector_signal": 0.8},
direct_employer=True,
recruiter=False,
extraction_confidence=Decimal("0.95"),
status=JobPosting.Status.ACTIVE,
)
def test_matching_job_gets_recommendation(matching_job, profile):
result = calculate_score(matching_job, profile)
assert result.score >= profile.recommendation_threshold
assert result.recommendation in {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
}
assert not result.hard_exclusions
def test_broad_relevant_skill_profile_is_not_diluted_without_limit(matching_job, profile):
profile.desired_skills = [
"microsoft 365",
"vmware",
"windows server",
"active directory",
"intune",
"networking",
"veeam",
"powershell",
"3cx",
"zabbix",
]
result = calculate_score(matching_job, profile)
assert result.components["skills"] == 12.5
assert result.evidence["skills"]["configured_count"] == 10
assert result.evidence["skills"]["match_target"] == 4
assert not any("Niet duidelijk teruggevonden" in concern for concern in result.concerns)
def test_skill_aliases_match_common_product_names(matching_job, profile):
profile.desired_skills = ["microsoft 365", "entra id", "group policy", "vmware"]
matching_job.skills_required = []
matching_job.skills_preferred = []
matching_job.description_text = "Beheer M365, Azure AD, GPO en vSphere voor meerdere klanten."
result = calculate_score(matching_job, profile)
assert result.components["skills"] == 25.0
assert result.evidence["skills"]["matched"] == [
"entra id",
"group policy",
"microsoft 365",
"vmware",
]
def test_experience_never_changes_score_or_recommendation(matching_job, profile):
profile.experience_years = 10
matching_job.analysis_features = {
**matching_job.analysis_features,
"experience_years_max": 8,
}
experienced = calculate_score(matching_job, profile)
profile.experience_years = 3
less_experienced = calculate_score(matching_job, profile)
assert experienced.score == less_experienced.score
assert experienced.recommendation == less_experienced.recommendation
assert "seniority" not in experienced.components
assert "experience" not in experienced.evidence
assert not any("ervaring" in concern.lower() for concern in less_experienced.concerns)
def test_ai_seniority_label_is_informational_only():
base = {"support_ratio": 0.1, "consultancy_ratio": 0.2, "travel_ratio": 0.0}
junior = scoring._ai_feature_score({**base, "seniority": "junior"})
expert = scoring._ai_feature_score({**base, "seniority": "expert"})
assert junior == expert
def test_hard_title_exclusion_wins(matching_job, profile):
matching_job.original_title = "IT Sales Infrastructure Engineer"
matching_job.save(update_fields=["original_title"])
result = calculate_score(matching_job, profile)
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
def test_short_title_exclusion_uses_word_boundaries(matching_job, profile):
profile.excluded_titles = ["ai"]
matching_job.original_title = "Maintenance IT Engineer"
allowed = calculate_score(matching_job, profile)
matching_job.original_title = "AI Engineer"
excluded = calculate_score(matching_job, profile)
assert not any("Uitgesloten titelterm" in reason for reason in allowed.hard_exclusions)
assert any("Uitgesloten titelterm: ai" in reason for reason in excluded.hard_exclusions)
def test_title_exclusion_matches_simple_english_plural(matching_job, profile):
profile.excluded_titles = ["developer"]
matching_job.original_title = "Full Stack Developers"
result = calculate_score(matching_job, profile)
assert any("Uitgesloten titelterm: developer" in reason for reason in result.hard_exclusions)
def test_non_it_vacancy_is_hidden_for_it_profile(matching_job, profile):
matching_job.original_title = "Spontane sollicitatie - logopedist"
matching_job.normalized_title = "spontane sollicitatie logopedist"
matching_job.description_text = "Werk met het digitale patiëntendossier in ons ziekenhuis."
result = calculate_score(matching_job, profile)
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
assert any("Geen aantoonbaar IT-signaal" in reason for reason in result.hard_exclusions)
assert result.evidence["it_relevance"]["relevant"] is False
def test_distance_boundary_inclusief(matching_job, profile, monkeypatch):
profile.max_distance_km = 50
profile.save(update_fields=["max_distance_km"])
monkeypatch.setattr(scoring, "haversine_km", lambda *_args, **_kwargs: 50.0)
matching_job.refresh_from_db()
matching_job.save(update_fields=["latitude", "longitude"])
result = calculate_score(matching_job, profile)
assert result.evidence["distance_km"] == 50.0
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
assert not any("boven maximum" in issue for issue in result.hard_exclusions)
def test_remote_job_heeft_geen_afstandsexclusie(matching_job, profile):
matching_job.workplace_type = JobPosting.Workplace.REMOTE
matching_job.postal_code = ""
matching_job.municipality = ""
matching_job.raw_location = ""
matching_job.save(
update_fields=["workplace_type", "postal_code", "municipality", "raw_location"]
)
matching_job.latitude = None
matching_job.longitude = None
result = calculate_score(matching_job, profile)
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
assert all("boven maximum" not in issue for issue in result.hard_exclusions)
def test_onbekende_locatie_leidt_niet_tot_automatische_uitsluiting(matching_job, profile):
matching_job.postal_code = ""
matching_job.municipality = ""
matching_job.raw_location = "onbekend"
matching_job.latitude = None
matching_job.longitude = None
matching_job.save(
update_fields=["postal_code", "municipality", "raw_location", "latitude", "longitude"]
)
result = calculate_score(matching_job, profile)
assert result.evidence["distance_km"] is None
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
def test_score_is_tijdzoneonafhankelijk(profile, user, matching_job):
utc_profile = profile
utc_profile.timezone = "UTC"
utc_profile.save(update_fields=["timezone"])
result_utc = calculate_score(matching_job, utc_profile)
ny_profile = profile.__class__.objects.create(
user=user,
name="UTC vergelijken",
is_active=False,
home_municipality=utc_profile.home_municipality,
home_latitude=utc_profile.home_latitude,
home_longitude=utc_profile.home_longitude,
max_distance_km=utc_profile.max_distance_km,
desired_titles=utc_profile.desired_titles,
excluded_titles=utc_profile.excluded_titles,
desired_skills=utc_profile.desired_skills,
excluded_skills=utc_profile.excluded_skills,
allowed_employment_types=utc_profile.allowed_employment_types,
preferred_workplace=utc_profile.preferred_workplace,
preferred_regions=utc_profile.preferred_regions,
excluded_regions=utc_profile.excluded_regions,
recommendation_threshold=utc_profile.recommendation_threshold,
top_match_threshold=utc_profile.top_match_threshold,
digest_time=utc_profile.digest_time,
quiet_hours_start=utc_profile.quiet_hours_start,
quiet_hours_end=utc_profile.quiet_hours_end,
learning_enabled=utc_profile.learning_enabled,
weights=utc_profile.weights,
timezone="America/New_York",
)
result_ny = calculate_score(matching_job, ny_profile)
assert result_ny.score == result_utc.score
def test_ai_score_influence_is_opt_in_and_bounded(matching_job, profile, monkeypatch):
profile.ai_scoring_enabled = True
profile.weights = {**profile.weights, "ai": 999}
profile.save(update_fields=["ai_scoring_enabled", "weights"])
monkeypatch.setattr(
scoring,
"analyze_job_text",
lambda *args, **kwargs: AiAnalysis(
features={
"support_ratio": 0.1,
"consultancy_ratio": 0.0,
"travel_ratio": 0.0,
"seniority": "senior",
"evidence": ["microsoft 365"],
},
summary_nl="Sterke technische rol.",
warnings=[],
model="local-model",
status=AiAnalysisCache.Status.OK,
error_category="",
cached=False,
),
)
result = calculate_score(matching_job, profile)
assert result.recommendation in {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
}
assert result.model_version == "local-model"
assert result.evidence["ai"]["status"] == AiAnalysisCache.Status.OK
assert result.evidence["ai"]["weight_applied"] == 20.0
assert "ai" in result.evidence
def test_ai_failure_keeps_deterministic_scoring(profile, matching_job, monkeypatch):
profile.ai_scoring_enabled = True
profile.weights = {**profile.weights, "ai": 10}
profile.save(update_fields=["ai_scoring_enabled", "weights"])
monkeypatch.setattr(
scoring,
"analyze_job_text",
lambda *args, **kwargs: AiAnalysis(
features={},
summary_nl="",
warnings=["service-timeout"],
model="local-model",
status=AiAnalysisCache.Status.TIMEOUT,
error_category="timeout",
cached=False,
),
)
result = calculate_score(matching_job, profile)
assert result.evidence["ai"]["status"] == AiAnalysisCache.Status.TIMEOUT
assert "AI-analyse" in "".join(result.concerns)
assert "components" in result.__dict__
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import _validate_production_security, env_int_range
def test_env_int_range_accepts_default_and_rejects_invalid_values(monkeypatch):
monkeypatch.delenv("TEST_BOUNDED_SETTING", raising=False)
assert env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23) == 6
monkeypatch.setenv("TEST_BOUNDED_SETTING", "24")
with pytest.raises(ImproperlyConfigured):
env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23)
monkeypatch.setenv("TEST_BOUNDED_SETTING", "morgen")
with pytest.raises(ImproperlyConfigured):
env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23)
def test_validate_production_security_skips_when_debug_mode_is_enabled():
_validate_production_security(
debug=True,
secret_key="dev-only-change-me",
allowed_hosts=[],
csrf_trusted_origins=[],
session_cookie_secure=False,
csrf_cookie_secure=False,
secure_ssl_redirect=False,
)
@pytest.mark.parametrize(
(
"secret_key",
"allowed_hosts",
"csrf_trusted_origins",
"session_cookie_secure",
"csrf_cookie_secure",
"secure_ssl_redirect",
),
[
("", ["example.org"], ["https://example.org"], True, True, True),
("change-me", ["example.org"], ["https://example.org"], True, True, True),
("too-short", ["example.org"], ["https://example.org"], True, True, True),
("a" * 64, [], ["https://example.org"], True, True, True),
("a" * 64, ["*"], ["https://example.org"], True, True, True),
("a" * 64, ["example.org"], [], True, True, True),
("a" * 64, ["example.org"], ["http://example.org"], True, True, True),
("a" * 64, ["example.org"], ["https://example.org"], False, True, True),
("a" * 64, ["example.org"], ["https://example.org"], True, False, True),
("a" * 64, ["example.org"], ["https://example.org"], True, True, False),
],
)
def test_validate_production_security_requires_secure_settings_for_prod(
secret_key,
allowed_hosts,
csrf_trusted_origins,
session_cookie_secure,
csrf_cookie_secure,
secure_ssl_redirect,
):
with pytest.raises(ImproperlyConfigured):
_validate_production_security(
debug=False,
secret_key=secret_key,
allowed_hosts=allowed_hosts,
csrf_trusted_origins=csrf_trusted_origins,
session_cookie_secure=session_cookie_secure,
csrf_cookie_secure=csrf_cookie_secure,
secure_ssl_redirect=secure_ssl_redirect,
)
def test_validate_production_security_rejects_writable_public_demo():
with pytest.raises(ImproperlyConfigured):
_validate_production_security(
debug=False,
secret_key="a" * 64,
allowed_hosts=["vacatureradar.itworx.tech"],
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
session_cookie_secure=True,
csrf_cookie_secure=True,
secure_ssl_redirect=True,
public_base_url="https://vacatureradar.itworx.tech",
demo_mode_enabled=True,
demo_read_only=False,
)
def test_validate_production_security_accepts_read_only_https_demo():
_validate_production_security(
debug=False,
secret_key="a" * 64,
allowed_hosts=["vacatureradar.itworx.tech"],
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
session_cookie_secure=True,
csrf_cookie_secure=True,
secure_ssl_redirect=True,
public_base_url="https://vacatureradar.itworx.tech",
demo_mode_enabled=True,
demo_read_only=True,
)
+80
View File
@@ -0,0 +1,80 @@
import pytest
from apps.jobs.models import JobSourceAlias, ScoreRun
from apps.jobs.services.skill_demand import build_skill_demand_report
from apps.sources.models import Source
@pytest.mark.django_db
def test_skill_demand_distinguishes_structured_evidence_and_text_signals(job, profile):
job.skills_required = ["Microsoft 365"]
job.skills_preferred = []
job.description_text = (
"Bouw governance voor Dynamics 365 en Microsoft Copilot. "
"Zorg ook voor high availability en gebruikersadoptie."
)
report = build_skill_demand_report(profile, jobs=[job])
by_key = {item.key: item for item in report.items}
assert by_key["microsoft 365"].covered is True
assert by_key["microsoft 365"].explicit_count == 1
assert by_key["dynamics 365"].covered is False
assert by_key["dynamics 365"].inferred_count == 1
assert by_key["microsoft copilot"].vacancy_count == 1
assert by_key["high availability"].learning_focus
assert report.gap_count >= 3
@pytest.mark.django_db
def test_skill_demand_filters_category_and_profile_coverage(job, profile):
job.skills_required = ["Microsoft 365", "FortiGate"]
job.description_text = "Microsoft 365 en FortiGate-beheer."
gaps = build_skill_demand_report(
profile,
category="netwerk-security",
coverage="gap",
jobs=[job],
)
assert [item.key for item in gaps.items] == ["fortinet"]
assert gaps.selected_category == "netwerk-security"
assert gaps.selected_coverage == "gap"
@pytest.mark.django_db
def test_generic_team_word_is_not_microsoft_teams_demand(job, profile):
job.skills_required = []
job.skills_preferred = []
job.description_text = "Je werkt samen met internationale teams aan klanttevredenheid."
report = build_skill_demand_report(profile, jobs=[job])
assert "microsoft teams" not in {item.key for item in report.items}
@pytest.mark.django_db
def test_disabled_source_vacancies_do_not_feed_learning_advice(job, profile, source):
JobSourceAlias.objects.create(
job=job,
source=source,
external_id="disabled-source-job",
url=job.canonical_url,
canonical_url=job.canonical_url,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=75,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
source.status = Source.Status.DISABLED
source.save(update_fields=["status"])
report = build_skill_demand_report(profile)
assert report.total_jobs == 0
assert report.signal_count == 0
+67
View File
@@ -0,0 +1,67 @@
from datetime import timedelta
import pytest
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.utils import timezone
from apps.sources.models import Source, SourceRun
@pytest.mark.django_db
def test_source_validation_and_scheduling(source):
now = timezone.now()
source.schedule_after_success(now=now)
source.refresh_from_db()
assert source.failure_count == 0
assert source.next_run_at == now + timedelta(minutes=source.crawl_interval_minutes)
source.schedule_after_failure(now=now, backoff_minutes=30)
source.refresh_from_db()
assert source.failure_count == 1
assert source.next_run_at == now + timedelta(minutes=30)
source.base_url = "https://other.example.org/jobs"
with pytest.raises(ValidationError):
source.full_clean()
source.base_url = "https://jobs.example.org/jobs"
source.policy = Source.Policy.DENY
source.status = Source.Status.ACTIVE
with pytest.raises(ValidationError):
source.full_clean()
@pytest.mark.django_db
def test_source_run_finish_updates_metrics(source):
run = SourceRun.objects.create(source=source)
run.finish(SourceRun.Status.SUCCESS, http_status=200, extracted_count=3)
run.refresh_from_db()
assert run.status == SourceRun.Status.SUCCESS
assert run.finished_at is not None
assert run.http_status == 200
assert run.extracted_count == 3
@pytest.mark.django_db(transaction=True)
def test_shared_ats_domain_accepts_distinct_public_feeds_but_not_duplicate_urls():
Source.objects.create(
name="Lever A",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/a?mode=json",
domain="api.lever.co",
)
Source.objects.create(
name="Lever B",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/b?mode=json",
domain="api.lever.co",
)
with pytest.raises(IntegrityError):
Source.objects.create(
name="Lever A dubbel",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/a?mode=json",
domain="api.lever.co",
)
+15
View File
@@ -0,0 +1,15 @@
import ssl
from pathlib import Path
import certifi
from apps.sources.services.tls import trusted_tls_context
def test_application_tls_context_uses_strict_pinned_ca_bundle():
context = trusted_tls_context()
assert Path(certifi.where()).is_file()
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
assert context.cert_store_stats()["x509_ca"] > 0