Publish curated VacatureRadar source snapshot
This commit is contained in:
commit
e29774cb7f
338 files changed
+32699
No files matched your search
Whitespace-only changes.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
== []
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user